From c2f6e5036e1fbf8eba666378fd8142db45bf11ac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 23 Dec 2024 15:56:12 +0000 Subject: [PATCH 001/222] 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 002/222] 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 003/222] 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 004/222] 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 005/222] 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 006/222] 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 007/222] 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 008/222] 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 009/222] 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 010/222] 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 011/222] 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 012/222] 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 013/222] 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 014/222] 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 015/222] 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 016/222] 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 017/222] 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 018/222] 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 019/222] 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 020/222] 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 021/222] 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 022/222] 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 023/222] 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 024/222] 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 025/222] 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 026/222] 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 027/222] 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 028/222] 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 029/222] 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 030/222] 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 031/222] 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 032/222] 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 033/222] 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 034/222] 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 035/222] 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 036/222] 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 037/222] 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 038/222] 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 039/222] 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 040/222] 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 041/222] 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 042/222] 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 043/222] 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 044/222] 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 045/222] 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 046/222] 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 047/222] 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 048/222] 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 049/222] 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 050/222] 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 051/222] 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 052/222] 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 053/222] 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 054/222] 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 055/222] 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 056/222] 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 057/222] 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 058/222] 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 059/222] 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 060/222] 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 061/222] 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 062/222] 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 063/222] 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 064/222] 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 065/222] 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 066/222] 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 067/222] 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 068/222] 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 069/222] 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 070/222] 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 071/222] 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 072/222] 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 073/222] 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 074/222] 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 075/222] 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 076/222] 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 077/222] 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 078/222] 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 079/222] 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 080/222] 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 081/222] 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 082/222] 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 083/222] 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 084/222] 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 085/222] 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 086/222] 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 087/222] 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 088/222] 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 089/222] 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 090/222] 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 091/222] 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 092/222] 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 093/222] 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 094/222] 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 095/222] 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 096/222] 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 097/222] 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 098/222] 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 099/222] 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 100/222] 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 101/222] 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 102/222] 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 103/222] 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 104/222] 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 105/222] 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 106/222] 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 107/222] 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 108/222] 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 109/222] 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 110/222] 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 111/222] 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 112/222] 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 113/222] 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 114/222] 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 115/222] 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 116/222] 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 117/222] 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 118/222] 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 119/222] 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 120/222] 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 121/222] 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 122/222] 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 123/222] 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 124/222] 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 125/222] 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 126/222] 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 127/222] 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 128/222] 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 129/222] 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 130/222] 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 131/222] 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 132/222] 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 133/222] 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 134/222] 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 135/222] 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 136/222] 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 137/222] 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 138/222] 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 139/222] 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 140/222] 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 141/222] 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 142/222] 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 143/222] =?UTF-8?q?Fix=20swapped=20letter=20order=20in=20"?= =?UTF-8?q?=C2=B0F"=20and=20"=C2=B0C"=20temperature=20units=20(#134750)?= 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 144/222] 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 145/222] 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 146/222] 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 147/222] 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 148/222] 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 149/222] 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 150/222] 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 151/222] 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 152/222] 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 153/222] 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 154/222] 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 155/222] 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 156/222] 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 157/222] 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 158/222] 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 159/222] 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 160/222] 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 161/222] 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 162/222] 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 163/222] 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 164/222] 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 165/222] 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 166/222] 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 167/222] 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 168/222] 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 169/222] 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 170/222] 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 44808c02f9cc7b927559dc1098729fa5d4a8df41 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 8 Jan 2025 22:51:37 +0100 Subject: [PATCH 171/222] =?UTF-8?q?Fix=20M=C3=A9t=C3=A9o-France=20setup=20?= =?UTF-8?q?in=20non=20French=20cities=20(because=20of=20failed=20next=20ra?= =?UTF-8?q?in=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 172/222] 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 173/222] 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 174/222] 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 175/222] 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 176/222] 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 177/222] 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 b8b7daff5aa2746b7295b86ae01d89effc4f2846 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 9 Jan 2025 22:23:53 +0100 Subject: [PATCH 178/222] 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 179/222] 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 180/222] 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 181/222] 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 182/222] 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 183/222] 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 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 184/222] 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 185/222] 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 186/222] 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 187/222] 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 188/222] 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 189/222] 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 190/222] 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 191/222] 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 192/222] 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 193/222] 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 194/222] 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 195/222] 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 196/222] 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 197/222] 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 198/222] 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 199/222] 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 200/222] 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 201/222] 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 202/222] 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 203/222] 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 204/222] 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 205/222] 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 206/222] 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 207/222] 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 208/222] 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 209/222] 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 210/222] 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 211/222] 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 212/222] 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 213/222] 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 214/222] 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 215/222] 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 216/222] 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 217/222] 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 218/222] 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 219/222] 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 220/222] 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 221/222] 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 222/222] 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"