From 0c44051d2ab8a78d8b979aaf950f5dc1c5c5bfa4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 24 Apr 2024 21:05:09 +0200 Subject: [PATCH 001/272] Bump version to 2024.5.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 ba83eca58d8..1abfe08b93c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 7e3038f6ee2..34c7d648795 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0.dev0" +version = "2024.5.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 67f6a84f5dc522939afe69b9f410dadf9697d278 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 26 Apr 2024 11:22:04 +0200 Subject: [PATCH 002/272] Use None as default value for strict connection cloud store (#116219) --- homeassistant/components/cloud/prefs.py | 15 +++++++++------ tests/components/cloud/test_prefs.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 9fce615128b..72207513ca9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -365,13 +365,16 @@ class CloudPreferences: @property def strict_connection(self) -> http.const.StrictConnectionMode: """Return the strict connection mode.""" - mode = self._prefs.get( - PREF_STRICT_CONNECTION, http.const.StrictConnectionMode.DISABLED - ) + mode = self._prefs.get(PREF_STRICT_CONNECTION) - if not isinstance(mode, http.const.StrictConnectionMode): + if mode is None: + # Set to default value + # We store None in the store as the default value to detect if the user has changed the + # value or not. + mode = http.const.StrictConnectionMode.DISABLED + elif not isinstance(mode, http.const.StrictConnectionMode): mode = http.const.StrictConnectionMode(mode) - return mode # type: ignore[no-any-return] + return mode async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" @@ -430,5 +433,5 @@ class CloudPreferences: PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, - PREF_STRICT_CONNECTION: http.const.StrictConnectionMode.DISABLED, + PREF_STRICT_CONNECTION: None, } diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 1ed2e1d524f..57715fe2bdf 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -197,3 +197,21 @@ async def test_strict_connection_convertion( await hass.async_block_till_done() assert cloud.client.prefs.strict_connection is mode + + +@pytest.mark.parametrize("storage_data", [{}, {PREF_STRICT_CONNECTION: None}]) +async def test_strict_connection_default( + hass: HomeAssistant, + cloud: MagicMock, + hass_storage: dict[str, Any], + storage_data: dict[str, Any], +) -> None: + """Test strict connection default values.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "data": storage_data, + } + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED From 09a18459ff8625b631fc524c4fc37adfe30ee714 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 12:28:40 +0200 Subject: [PATCH 003/272] Restore default timezone after electric_kiwi sensor tests (#116217) --- tests/components/electric_kiwi/conftest.py | 3 --- tests/components/electric_kiwi/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 8819b1e134d..8052ae5e129 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator from time import time from unittest.mock import AsyncMock, patch -import zoneinfo from electrickiwi_api.model import AccountBalance, Hop, HopIntervals import pytest @@ -24,8 +23,6 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -TZ_NAME = "Pacific/Auckland" -TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) YieldFixture = Generator[AsyncMock, None, None] ComponentSetup = Callable[[], Awaitable[bool]] diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index f91e4d9c58c..a247497b263 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, Mock +import zoneinfo from freezegun import freeze_time import pytest @@ -19,10 +20,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.util.dt as dt_util -from .conftest import TIMEZONE, ComponentSetup, YieldFixture +from .conftest import ComponentSetup, YieldFixture from tests.common import MockConfigEntry +DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE +TEST_TZ_NAME = "Pacific/Auckland" +TEST_TIMEZONE = zoneinfo.ZoneInfo(TEST_TZ_NAME) + + +@pytest.fixture(autouse=True) +def restore_timezone(): + """Restore default timezone.""" + yield + + dt_util.set_default_time_zone(DEFAULT_TIME_ZONE) + @pytest.mark.parametrize( ("sensor", "sensor_state"), @@ -124,8 +137,8 @@ async def test_check_and_move_time(ek_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" hop = await ek_api(Mock()).get_hop() - test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TIMEZONE) - dt_util.set_default_time_zone(TIMEZONE) + test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) + dt_util.set_default_time_zone(TEST_TIMEZONE) with freeze_time(test_time): value = _check_and_move_time(hop, "4:00 PM") From 56f2f10a17c42ba750c4cdc81ac34c0a17bc1eeb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 13:49:43 +0200 Subject: [PATCH 004/272] Fix flapping trafikverket tests (#116238) * Fix flapping trafikverket tests * Fix copy-paste mistake --- .../components/trafikverket_train/conftest.py | 38 +++++++++++-------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/tests/components/trafikverket_train/conftest.py b/tests/components/trafikverket_train/conftest.py index 880701e8bdc..7221d96bae2 100644 --- a/tests/components/trafikverket_train/conftest.py +++ b/tests/components/trafikverket_train/conftest.py @@ -25,6 +25,25 @@ async def load_integration_from_entry( get_train_stop: TrainStop, ) -> MockConfigEntry: """Set up the Trafikverket Train integration in Home Assistant.""" + + async def setup_config_entry_with_mocked_data(config_entry_id: str) -> None: + """Set up a config entry with mocked trafikverket data.""" + with ( + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", + return_value=get_trains, + ), + patch( + "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", + return_value=get_train_stop, + ), + patch( + "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", + ), + ): + await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + config_entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -34,6 +53,8 @@ async def load_integration_from_entry( unique_id="stockholmc-uppsalac--['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", ) config_entry.add_to_hass(hass) + await setup_config_entry_with_mocked_data(config_entry.entry_id) + config_entry2 = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, @@ -42,22 +63,7 @@ async def load_integration_from_entry( unique_id="stockholmc-uppsalac-1100-['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']", ) config_entry2.add_to_hass(hass) - - with ( - patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_next_train_stops", - return_value=get_trains, - ), - patch( - "homeassistant.components.trafikverket_train.coordinator.TrafikverketTrain.async_get_train_stop", - return_value=get_train_stop, - ), - patch( - "homeassistant.components.trafikverket_train.TrafikverketTrain.async_get_train_station", - ), - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await setup_config_entry_with_mocked_data(config_entry2.entry_id) return config_entry From 7c64658aa92552bcaffa86b0d0b923c41ff9bb2e Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 26 Apr 2024 13:03:16 +0100 Subject: [PATCH 005/272] Fix state classes for ovo energy sensors (#116225) * Fix state classes for ovo energy sensors * Restore monetary values Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ovo_energy/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 5b16e8cdef5..3012a130a1a 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -54,7 +54,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( key=KEY_LAST_ELECTRICITY_COST, translation_key=KEY_LAST_ELECTRICITY_COST, device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value=lambda usage: usage.electricity[-1].cost.amount if usage.electricity[-1].cost is not None else None, @@ -88,7 +88,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( key=KEY_LAST_GAS_COST, translation_key=KEY_LAST_GAS_COST, device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value=lambda usage: usage.gas[-1].cost.amount if usage.gas[-1].cost is not None else None, From b582d51a8a674ef643a4c3770ac722c86eae8972 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 26 Apr 2024 14:31:37 +0200 Subject: [PATCH 006/272] Remove myself as codeowner for Harmony (#116241) * Remove myself as codeowner * Update CODEOWNERS * Format --- CODEOWNERS | 4 ++-- homeassistant/components/harmony/manifest.json | 8 +------- 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 45d4ad6053e..f954675f4d4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -556,8 +556,8 @@ build.json @home-assistant/supervisor /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core /tests/components/hardware/ @home-assistant/core -/homeassistant/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan -/tests/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan +/homeassistant/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan +/tests/components/harmony/ @ehendrix23 @bdraco @mkeesey @Aohzan /homeassistant/components/hassio/ @home-assistant/supervisor /tests/components/hassio/ @home-assistant/supervisor /homeassistant/components/hdmi_cec/ @inytar diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json index c6a6327046d..8acc4307d1f 100644 --- a/homeassistant/components/harmony/manifest.json +++ b/homeassistant/components/harmony/manifest.json @@ -1,13 +1,7 @@ { "domain": "harmony", "name": "Logitech Harmony Hub", - "codeowners": [ - "@ehendrix23", - "@bramkragten", - "@bdraco", - "@mkeesey", - "@Aohzan" - ], + "codeowners": ["@ehendrix23", "@bdraco", "@mkeesey", "@Aohzan"], "config_flow": true, "dependencies": ["remote", "switch"], "documentation": "https://www.home-assistant.io/integrations/harmony", From 10be8f9683eaa017fa120ab90d3a3d89dda7cad1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 15:14:23 +0200 Subject: [PATCH 007/272] Simplify timezone setting in recorder test (#116220) --- tests/components/recorder/test_init.py | 99 ++++++++------------ tests/components/recorder/test_statistics.py | 36 ++----- tests/conftest.py | 6 +- 3 files changed, 50 insertions(+), 91 deletions(-) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 206c356bad8..e3fec10f86b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -554,7 +554,7 @@ def test_saving_state_with_commit_interval_zero( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving a state with a commit interval of zero.""" - hass = hass_recorder({"commit_interval": 0}) + hass = hass_recorder(config={"commit_interval": 0}) assert get_instance(hass).commit_interval == 0 entity_id = "test.recorder" @@ -611,7 +611,7 @@ def test_saving_state_include_domains( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder({"include": {"domains": "test2"}}) + hass = hass_recorder(config={"include": {"domains": "test2"}}) states = _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() @@ -622,7 +622,7 @@ def test_saving_state_include_domains_globs( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - {"include": {"domains": "test2", "entity_globs": "*.included_*"}} + config={"include": {"domains": "test2", "entity_globs": "*.included_*"}} ) states = _add_entities( hass, ["test.recorder", "test2.recorder", "test3.included_entity"] @@ -644,7 +644,7 @@ def test_saving_state_incl_entities( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder({"include": {"entities": "test2.recorder"}}) + hass = hass_recorder(config={"include": {"entities": "test2.recorder"}}) states = _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() @@ -705,7 +705,7 @@ def test_saving_state_exclude_domains( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder({"exclude": {"domains": "test"}}) + hass = hass_recorder(config={"exclude": {"domains": "test"}}) states = _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() @@ -716,7 +716,7 @@ def test_saving_state_exclude_domains_globs( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - {"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}} + config={"exclude": {"domains": "test", "entity_globs": "*.excluded_*"}} ) states = _add_entities( hass, ["test.recorder", "test2.recorder", "test2.excluded_entity"] @@ -729,7 +729,7 @@ def test_saving_state_exclude_entities( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test saving and restoring a state.""" - hass = hass_recorder({"exclude": {"entities": "test.recorder"}}) + hass = hass_recorder(config={"exclude": {"entities": "test.recorder"}}) states = _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 1 assert _state_with_context(hass, "test2.recorder").as_dict() == states[0].as_dict() @@ -740,7 +740,10 @@ def test_saving_state_exclude_domain_include_entity( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - {"include": {"entities": "test.recorder"}, "exclude": {"domains": "test"}} + config={ + "include": {"entities": "test.recorder"}, + "exclude": {"domains": "test"}, + } ) states = _add_entities(hass, ["test.recorder", "test2.recorder"]) assert len(states) == 2 @@ -751,7 +754,7 @@ def test_saving_state_exclude_domain_glob_include_entity( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - { + config={ "include": {"entities": ["test.recorder", "test.excluded_entity"]}, "exclude": {"domains": "test", "entity_globs": "*._excluded_*"}, } @@ -767,7 +770,10 @@ def test_saving_state_include_domain_exclude_entity( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - {"exclude": {"entities": "test.recorder"}, "include": {"domains": "test"}} + config={ + "exclude": {"entities": "test.recorder"}, + "include": {"domains": "test"}, + } ) states = _add_entities(hass, ["test.recorder", "test2.recorder", "test.ok"]) assert len(states) == 1 @@ -780,7 +786,7 @@ def test_saving_state_include_domain_glob_exclude_entity( ) -> None: """Test saving and restoring a state.""" hass = hass_recorder( - { + config={ "exclude": {"entities": ["test.recorder", "test2.included_entity"]}, "include": {"domains": "test", "entity_globs": "*._included_*"}, } @@ -985,12 +991,9 @@ def run_tasks_at_time(hass, test_time): @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test periodic purge scheduling.""" - hass = hass_recorder() - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(timezone=timezone) + tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an @@ -1040,20 +1043,15 @@ def test_auto_purge(hass_recorder: Callable[..., HomeAssistant]) -> None: assert len(purge_old_data.mock_calls) == 1 assert len(periodic_db_cleanups.mock_calls) == 1 - dt_util.set_default_time_zone(original_tz) - @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_on_second_sunday( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does a repack on the 2nd sunday.""" - hass = hass_recorder() - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(timezone=timezone) + tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an @@ -1084,20 +1082,15 @@ def test_auto_purge_auto_repack_on_second_sunday( assert args[2] is True # repack assert len(periodic_db_cleanups.mock_calls) == 1 - dt_util.set_default_time_zone(original_tz) - @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_auto_repack_disabled_on_second_sunday( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not auto repack on the 2nd sunday if disabled.""" - hass = hass_recorder({CONF_AUTO_REPACK: False}) - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(config={CONF_AUTO_REPACK: False}, timezone=timezone) + tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an @@ -1128,20 +1121,15 @@ def test_auto_purge_auto_repack_disabled_on_second_sunday( assert args[2] is False # repack assert len(periodic_db_cleanups.mock_calls) == 1 - dt_util.set_default_time_zone(original_tz) - @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_no_auto_repack_on_not_second_sunday( hass_recorder: Callable[..., HomeAssistant], ) -> None: """Test periodic purge scheduling does not do a repack unless its the 2nd sunday.""" - hass = hass_recorder() - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(timezone=timezone) + tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. Exercise this behavior by # firing time changed events and advancing the clock around this time. Pick an @@ -1173,18 +1161,13 @@ def test_auto_purge_no_auto_repack_on_not_second_sunday( assert args[2] is False # repack assert len(periodic_db_cleanups.mock_calls) == 1 - dt_util.set_default_time_zone(original_tz) - @pytest.mark.parametrize("enable_nightly_purge", [True]) def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test periodic db cleanup still run when auto purge is disabled.""" - hass = hass_recorder({CONF_AUTO_PURGE: False}) - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(config={CONF_AUTO_PURGE: False}, timezone=timezone) + tz = dt_util.get_time_zone(timezone) # Purging is scheduled to happen at 4:12am every day. We want # to verify that when auto purge is disabled periodic db cleanups @@ -1212,18 +1195,13 @@ def test_auto_purge_disabled(hass_recorder: Callable[..., HomeAssistant]) -> Non purge_old_data.reset_mock() periodic_db_cleanups.reset_mock() - dt_util.set_default_time_zone(original_tz) - @pytest.mark.parametrize("enable_statistics", [True]) def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) -> None: """Test periodic statistics scheduling.""" - hass = hass_recorder() - - original_tz = dt_util.DEFAULT_TIME_ZONE - - tz = dt_util.get_time_zone("Europe/Copenhagen") - dt_util.set_default_time_zone(tz) + timezone = "Europe/Copenhagen" + hass = hass_recorder(timezone=timezone) + tz = dt_util.get_time_zone(timezone) stats_5min = [] stats_hourly = [] @@ -1302,8 +1280,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - assert len(stats_5min) == 3 assert len(stats_hourly) == 1 - dt_util.set_default_time_zone(original_tz) - def test_statistics_runs_initiated(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test statistics_runs is initiated when DB is created.""" @@ -1719,7 +1695,10 @@ async def test_database_corruption_while_running( def test_entity_id_filter(hass_recorder: Callable[..., HomeAssistant]) -> None: """Test that entity ID filtering filters string and list.""" hass = hass_recorder( - {"include": {"domains": "hello"}, "exclude": {"domains": "hidden_domain"}} + config={ + "include": {"domains": "hello"}, + "exclude": {"domains": "hidden_domain"}, + } ) event_types = ("hello",) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index d469db8831e..19a0fe98953 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -1119,9 +1119,7 @@ def test_daily_statistics_sum( timezone, ) -> None: """Test daily statistics.""" - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1291,8 +1289,6 @@ def test_daily_statistics_sum( ) assert stats == {} - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) - @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") @@ -1302,9 +1298,7 @@ def test_weekly_statistics_mean( timezone, ) -> None: """Test weekly statistics.""" - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1429,8 +1423,6 @@ def test_weekly_statistics_mean( ) assert stats == {} - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) - @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") @@ -1440,9 +1432,7 @@ def test_weekly_statistics_sum( timezone, ) -> None: """Test weekly statistics.""" - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1612,8 +1602,6 @@ def test_weekly_statistics_sum( ) assert stats == {} - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) - @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") @@ -1623,9 +1611,7 @@ def test_monthly_statistics_sum( timezone, ) -> None: """Test monthly statistics.""" - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -1851,8 +1837,6 @@ def test_monthly_statistics_sum( ) assert stats == {} - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) - def test_cache_key_for_generate_statistics_during_period_stmt() -> None: """Test cache key for _generate_statistics_during_period_stmt.""" @@ -1946,9 +1930,7 @@ def test_change( timezone, ) -> None: """Test deriving change from sum statistic.""" - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -2273,8 +2255,6 @@ def test_change( ) assert stats == {} - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) - @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") @@ -2288,9 +2268,7 @@ def test_change_with_none( This tests the behavior when some record has None sum. The calculated change is not expected to be correct, but we should not raise on this error. """ - dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) - - hass = hass_recorder() + hass = hass_recorder(timezone=timezone) wait_recording_done(hass) assert "Compiling statistics for" not in caplog.text assert "Statistics already compiled" not in caplog.text @@ -2502,5 +2480,3 @@ def test_change_with_none( types={"change"}, ) assert stats == {} - - dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) diff --git a/tests/conftest.py b/tests/conftest.py index 7efd4246a1f..4feae83798f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1404,8 +1404,12 @@ def hass_recorder( ), ): - def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: + def setup_recorder( + *, config: dict[str, Any] | None = None, timezone: str | None = None + ) -> HomeAssistant: """Set up with params.""" + if timezone is not None: + hass.config.set_time_zone(timezone) init_recorder_component(hass, config, recorder_db_url) hass.start() hass.block_till_done() From 63dffbcce18fdb197832359e9f18a55b56673a18 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 26 Apr 2024 15:40:32 +0200 Subject: [PATCH 008/272] Update frontend to 20240426.0 (#116230) --- 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 ad63bdbed84..a5446f688ba 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==20240424.1"] + "requirements": ["home-assistant-frontend==20240426.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 442db45e714..1b4223d7b33 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5cfaef1fcb7..c294b61870e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 403bb8c965d..585236e2722 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From e909074dfbdf2b9154aeec73be07fe04d83466e1 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 26 Apr 2024 23:44:13 +1000 Subject: [PATCH 009/272] Breakfix to handle null value in Teslemetry (#116206) * Fixes * Remove unused test --- 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 75794c7cdec..be34386a508 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -119,7 +119,7 @@ class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator): # Convert Wall Connectors from array to dict data["response"]["wall_connectors"] = { - wc["din"]: wc for wc in data["response"].get("wall_connectors", []) + wc["din"]: wc for wc in (data["response"].get("wall_connectors") or []) } return data["response"] From c9301850be87f9114ad26de3ec14be85ec20b2de Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 16:03:49 +0200 Subject: [PATCH 010/272] Reduce scope of bluetooth test fixtures (#116210) --- tests/components/bluetooth/conftest.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/bluetooth/conftest.py b/tests/components/bluetooth/conftest.py index d4056c1e38e..17fbb318248 100644 --- a/tests/components/bluetooth/conftest.py +++ b/tests/components/bluetooth/conftest.py @@ -8,21 +8,21 @@ import habluetooth.util as habluetooth_utils import pytest -@pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="session") +@pytest.fixture(name="disable_bluez_manager_socket", autouse=True, scope="package") def disable_bluez_manager_socket(): """Mock the bluez manager socket.""" with patch.object(bleak_manager, "get_global_bluez_manager_with_timeout"): yield -@pytest.fixture(name="disable_dbus_socket", autouse=True, scope="session") +@pytest.fixture(name="disable_dbus_socket", autouse=True, scope="package") def disable_dbus_socket(): """Mock the dbus message bus to avoid creating a socket.""" with patch.object(message_bus, "MessageBus"): yield -@pytest.fixture(name="disable_bluetooth_auto_recovery", autouse=True, scope="session") +@pytest.fixture(name="disable_bluetooth_auto_recovery", autouse=True, scope="package") def disable_bluetooth_auto_recovery(): """Mock out auto recovery.""" with patch.object(habluetooth_utils, "recover_adapter"): From aa65f21be7b32e279767ebaf2b2d00ffd6a32b69 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 16:05:23 +0200 Subject: [PATCH 011/272] Fix flapping recorder tests (#116239) --- homeassistant/core.py | 5 +++-- tests/components/recorder/test_init.py | 10 +++------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index a3150adc221..604840e542d 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -972,10 +972,11 @@ class HomeAssistant: target = cast(Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], target) return self.async_run_hass_job(HassJob(target), *args) - def block_till_done(self) -> None: + def block_till_done(self, wait_background_tasks: bool = False) -> None: """Block until all pending work is done.""" asyncio.run_coroutine_threadsafe( - self.async_block_till_done(), self.loop + self.async_block_till_done(wait_background_tasks=wait_background_tasks), + self.loop, ).result() async def async_block_till_done(self, wait_background_tasks: bool = False) -> None: diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index e3fec10f86b..f0609f82229 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -981,11 +981,12 @@ async def test_defaults_set(hass: HomeAssistant) -> None: assert recorder_config["purge_keep_days"] == 10 -def run_tasks_at_time(hass, test_time): +def run_tasks_at_time(hass: HomeAssistant, test_time: datetime) -> None: """Advance the clock and wait for any callbacks to finish.""" fire_time_changed(hass, test_time) - hass.block_till_done() + hass.block_till_done(wait_background_tasks=True) get_instance(hass).block_till_done() + hass.block_till_done(wait_background_tasks=True) @pytest.mark.parametrize("enable_nightly_purge", [True]) @@ -1225,7 +1226,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - test_time = datetime(now.year + 2, 1, 1, 4, 51, 0, tzinfo=tz) freezer.move_to(test_time.isoformat()) run_tasks_at_time(hass, test_time) - hass.block_till_done() hass.bus.listen( EVENT_RECORDER_5MIN_STATISTICS_GENERATED, async_5min_stats_updated_listener @@ -1245,7 +1245,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - freezer.move_to(test_time.isoformat()) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 - hass.block_till_done() assert len(stats_5min) == 1 assert len(stats_hourly) == 0 @@ -1256,7 +1255,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - freezer.move_to(test_time.isoformat()) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 - hass.block_till_done() assert len(stats_5min) == 2 assert len(stats_hourly) == 1 @@ -1267,7 +1265,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - freezer.move_to(test_time.isoformat()) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 0 - hass.block_till_done() assert len(stats_5min) == 2 assert len(stats_hourly) == 1 @@ -1276,7 +1273,6 @@ def test_auto_statistics(hass_recorder: Callable[..., HomeAssistant], freezer) - freezer.move_to(test_time.isoformat()) run_tasks_at_time(hass, test_time) assert len(compile_statistics.mock_calls) == 1 - hass.block_till_done() assert len(stats_5min) == 3 assert len(stats_hourly) == 1 From a25b2168a312713fafd73e769ab19f71a736ef6b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 16:08:58 +0200 Subject: [PATCH 012/272] Reduce scope of ZHA test fixtures (#116208) --- tests/components/zha/conftest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 7d3722b5037..54440a0f75b 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -42,7 +42,7 @@ FIXTURE_GRP_NAME = "fixture group" COUNTER_NAMES = ["counter_1", "counter_2", "counter_3"] -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def disable_request_retry_delay(): """Disable ZHA request retrying delay to speed up failures.""" @@ -53,7 +53,7 @@ def disable_request_retry_delay(): yield -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def globally_load_quirks(): """Load quirks automatically so that ZHA tests run deterministically in isolation. From ff4b8fa5e369b808ec2667a6a5772f710ba5a01e Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 27 Apr 2024 09:24:11 +0200 Subject: [PATCH 013/272] Don't create event entries for lighting4 rfxtrx devices (#115716) These have no standardized command need to be reworked in the backing library to support exposing as events. Fixes #115545 --- homeassistant/components/rfxtrx/event.py | 6 +++++- tests/components/rfxtrx/test_event.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 7e73919aacd..5c3944dc74b 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from . import DeviceTuple, RfxtrxEntity, async_setup_platform_entry +from .const import DEVICE_PACKET_TYPE_LIGHTING4 _LOGGER = logging.getLogger(__name__) @@ -27,7 +28,10 @@ async def async_setup_entry( """Set up config entry.""" def _supported(event: RFXtrxEvent) -> bool: - return isinstance(event, (ControlEvent, SensorEvent)) + return ( + isinstance(event, (ControlEvent, SensorEvent)) + and event.device.packettype != DEVICE_PACKET_TYPE_LIGHTING4 + ) def _constructor( event: RFXtrxEvent, diff --git a/tests/components/rfxtrx/test_event.py b/tests/components/rfxtrx/test_event.py index 1a4305d97f6..035949efe3b 100644 --- a/tests/components/rfxtrx/test_event.py +++ b/tests/components/rfxtrx/test_event.py @@ -10,6 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.rfxtrx import get_rfx_object from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import setup_rfx_test_cfg @@ -101,3 +102,25 @@ async def test_invalid_event_type( await hass.async_block_till_done() assert hass.states.get("event.arc_c1") == state + + +async def test_ignoring_lighting4(hass: HomeAssistant, rfxtrx) -> None: + """Test with 1 sensor.""" + entry = await setup_rfx_test_cfg( + hass, + devices={ + "0913000022670e013970": { + "data_bits": 4, + "command_on": 0xE, + "command_off": 0x7, + } + }, + ) + + registry = er.async_get(hass) + entries = [ + entry + for entry in registry.entities.get_entries_for_config_entry_id(entry.entry_id) + if entry.domain == Platform.EVENT + ] + assert entries == [] From 8bae614d4e500faa1bcb2c69825b5be5b1560f51 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 27 Apr 2024 03:24:23 -0400 Subject: [PATCH 014/272] Bump zwave-js-server-python to 0.55.4 (#116278) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a06de5cb8ee..83a139331bb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.4"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index c294b61870e..d08193f4636 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2965,7 +2965,7 @@ zigpy==0.64.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.3 +zwave-js-server-python==0.55.4 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 585236e2722..6600adaea76 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2303,7 +2303,7 @@ zigpy-znp==0.12.1 zigpy==0.64.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.3 +zwave-js-server-python==0.55.4 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 09ebbfa0e1b95acf6201ff460d6b509811c61b26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:24:55 -0500 Subject: [PATCH 015/272] Move thread safety check in device_registry sooner (#116264) It turns out we have custom components that are writing to the device registry using the async APIs from threads. We now catch it at the point async_fire is called. Instead we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. --- homeassistant/helpers/device_registry.py | 6 ++- tests/helpers/test_device_registry.py | 47 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index aec5dbc6c4a..6b653784824 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -904,6 +904,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not new_values: return old + self.hass.verify_event_loop_thread("async_update_device") new = attr.evolve(old, **new_values) self.devices[device_id] = new @@ -923,13 +924,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: data = {"action": "update", "device_id": new.id, "changes": old_values} - self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, data) + self.hass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data) return new @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + self.hass.verify_event_loop_thread("async_remove_device") device = self.devices.pop(device_id) self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, @@ -941,7 +943,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: self.async_update_device(other_device.id, via_device_id=None) - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, _EventDeviceRegistryUpdatedData_CreateRemove( action="remove", device_id=device_id diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index ee895e3fd3e..6b167f8ee49 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,6 +2,7 @@ from collections.abc import Iterable from contextlib import AbstractContextManager, nullcontext +from functools import partial import time from typing import Any from unittest.mock import patch @@ -2473,3 +2474,49 @@ async def test_device_name_translation_placeholders_errors( ) assert expected_error in caplog.text + + +async def test_async_get_or_create_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_get_or_create raises when called from wrong thread.""" + + with pytest.raises( + RuntimeError, + match="Detected code that calls async_update_device from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial( + device_registry.async_get_or_create, + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers=set(), + manufacturer="manufacturer", + model="model", + ) + ) + + +async def test_async_remove_device_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_remove_device raises when called from wrong thread.""" + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers=set(), + manufacturer="manufacturer", + model="model", + ) + + with pytest.raises( + RuntimeError, + match="Detected code that calls async_remove_device from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + device_registry.async_remove_device, device.id + ) From 4a79e750a1b67095229ca575be73fe835d85e86e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 27 Apr 2024 09:25:08 +0200 Subject: [PATCH 016/272] Add HA version to cache key (#116159) * Add HA version to cache key * Add comment --- .github/workflows/ci.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 580aba9752c..40a3b064887 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -92,8 +92,10 @@ jobs: uses: actions/checkout@v4.1.4 - name: Generate partial Python venv restore key id: generate_python_cache_key - run: >- - echo "key=venv-${{ env.CACHE_VERSION }}-${{ + run: | + # Include HA_SHORT_VERSION to force the immediate creation + # of a new uv cache entry after a version bump. + echo "key=venv-${{ env.CACHE_VERSION }}-${{ env.HA_SHORT_VERSION }}-${{ hashFiles('requirements_test.txt', 'requirements_test_pre_commit.txt') }}-${{ hashFiles('requirements.txt') }}-${{ hashFiles('requirements_all.txt') }}-${{ From 244433aecaf8602781a7ab20a17d67f2734fe1ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:25:19 -0500 Subject: [PATCH 017/272] Move thread safety check in entity_registry sooner (#116263) * Move thread safety check in entity_registry sooner It turns out we have a lot of custom components that are writing to the entity registry using the async APIs from threads. We now catch it at the point async_fire is called. Instread we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. * coverage * Apply suggestions from code review --- homeassistant/helpers/entity_registry.py | 10 ++++-- tests/helpers/test_entity_registry.py | 44 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 436fc5a18de..589b379cf08 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -819,6 +819,7 @@ class EntityRegistry(BaseRegistry): unit_of_measurement=unit_of_measurement, ) + self.hass.verify_event_loop_thread("async_get_or_create") _validate_item( self.hass, domain, @@ -879,7 +880,7 @@ class EntityRegistry(BaseRegistry): _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_ENTITY_REGISTRY_UPDATED, _EventEntityRegistryUpdatedData_CreateRemove( action="create", entity_id=entity_id @@ -891,6 +892,7 @@ class EntityRegistry(BaseRegistry): @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" + self.hass.verify_event_loop_thread("async_remove") entity = self.entities.pop(entity_id) config_entry_id = entity.config_entry_id key = (entity.domain, entity.platform, entity.unique_id) @@ -904,7 +906,7 @@ class EntityRegistry(BaseRegistry): platform=entity.platform, unique_id=entity.unique_id, ) - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_ENTITY_REGISTRY_UPDATED, _EventEntityRegistryUpdatedData_CreateRemove( action="remove", entity_id=entity_id @@ -1085,6 +1087,8 @@ class EntityRegistry(BaseRegistry): if not new_values: return old + self.hass.verify_event_loop_thread("_async_update_entity") + new = self.entities[entity_id] = attr.evolve(old, **new_values) self.async_schedule_save() @@ -1098,7 +1102,7 @@ class EntityRegistry(BaseRegistry): if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id - self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, data) + self.hass.bus.async_fire_internal(EVENT_ENTITY_REGISTRY_UPDATED, data) return new diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 60971d98df2..bc3b2d6f705 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,6 +1,7 @@ """Tests for the Entity Registry.""" from datetime import timedelta +from functools import partial from typing import Any from unittest.mock import patch @@ -1988,3 +1989,46 @@ async def test_entries_for_category(entity_registry: er.EntityRegistry) -> None: assert not er.async_entries_for_category(entity_registry, "", "id") assert not er.async_entries_for_category(entity_registry, "scope1", "unknown") assert not er.async_entries_for_category(entity_registry, "scope1", "") + + +async def test_get_or_create_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_get_or_create_from a thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls async_get_or_create from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + entity_registry.async_get_or_create, "light", "hue", "1234" + ) + + +async def test_async_update_entity_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_get_or_create from a thread.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + with pytest.raises( + RuntimeError, + match="Detected code that calls _async_update_entity from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial( + entity_registry.async_update_entity, + entry.entity_id, + new_unique_id="5678", + ) + ) + + +async def test_async_remove_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_remove from a thread.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + with pytest.raises( + RuntimeError, + match="Detected code that calls async_remove from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(entity_registry.async_remove, entry.entity_id) From 97d151d1c68168b3ff5f13c11bc3b90b7fa99d6d Mon Sep 17 00:00:00 2001 From: On Freund Date: Sat, 27 Apr 2024 10:26:11 +0300 Subject: [PATCH 018/272] Avoid blocking the event loop when unloading Monoprice (#116141) * Avoid blocking the event loop when unloading Monoprice * Code review suggestions --- .../components/monoprice/__init__.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 57282fb6545..c7683ebedd6 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -57,11 +57,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) + if not unload_ok: + return False - return unload_ok + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + def _cleanup(monoprice) -> None: + """Destroy the Monoprice object. + + Destroying the Monoprice closes the serial connection, do it in an executor so the garbage + collection does not block. + """ + del monoprice + + monoprice = hass.data[DOMAIN][entry.entry_id][MONOPRICE_OBJECT] + hass.data[DOMAIN].pop(entry.entry_id) + + await hass.async_add_executor_job(_cleanup, monoprice) + + return True async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: From c1572d9600ae921647c900338a6f1ed73edd9f46 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 27 Apr 2024 09:26:26 +0200 Subject: [PATCH 019/272] Handle invalid device type in onewire (#116153) * Make device type optional in onewire * Add comment --- homeassistant/components/onewire/binary_sensor.py | 2 +- homeassistant/components/onewire/model.py | 2 +- homeassistant/components/onewire/onewirehub.py | 10 +++++++--- homeassistant/components/onewire/sensor.py | 4 ++-- homeassistant/components/onewire/switch.py | 2 +- tests/components/onewire/const.py | 8 +++++++- .../onewire/snapshots/test_binary_sensor.ambr | 12 ++++++++++++ tests/components/onewire/snapshots/test_sensor.ambr | 12 ++++++++++++ tests/components/onewire/snapshots/test_switch.ambr | 12 ++++++++++++ 9 files changed, 55 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index d2e66609103..3c2ca3529cc 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -117,7 +117,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: device_type = device.type device_info = device.device_info device_sub_type = "std" - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index 9deaca2d121..a59953dcd25 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -16,4 +16,4 @@ class OWDeviceDescription: family: str id: str path: str - type: str + type: str | None diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index b01cc6ba3d6..2dc617ba039 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -45,7 +45,7 @@ DEVICE_MANUFACTURER = { _LOGGER = logging.getLogger(__name__) -def _is_known_device(device_family: str, device_type: str) -> bool: +def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" if device_family in ("7E", "EF"): # EDS or HobbyBoard return device_type in DEVICE_SUPPORT[device_family] @@ -144,11 +144,15 @@ class OneWireHub: return devices - def _get_device_type(self, device_path: str) -> str: + def _get_device_type(self, device_path: str) -> str | None: """Get device model.""" if TYPE_CHECKING: assert self.owproxy - device_type = self.owproxy.read(f"{device_path}type").decode() + try: + device_type = self.owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None _LOGGER.debug("read `%stype`: %s", device_path, device_type) if device_type == "EDS": device_type = self.owproxy.read(f"{device_path}device_type").decode() diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 46f18842d51..3e43df4dddd 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -377,10 +377,10 @@ def get_entities( device_info = device.device_info device_sub_type = "std" device_path = device.path - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type - elif "7E" in family: + elif device_type and "7E" in family: device_sub_type = "EDS" family = device_type elif "A6" in family: diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 41276218540..94a7d41ab85 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -178,7 +178,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: device_id = device.id device_info = device.device_info device_sub_type = "std" - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type elif "A6" in family: diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index e5f8ac575e9..a1bab9807d5 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,6 +1,6 @@ """Constants for 1-Wire integration.""" -from pyownet.protocol import Error as ProtocolError +from pyownet.protocol import ProtocolError from homeassistant.components.onewire.const import Platform @@ -58,6 +58,12 @@ MOCK_OWPROXY_DEVICES = { {ATTR_INJECT_READS: b" 248125"}, ], }, + "16.111111111111": { + # Test case for issue #115984, where the device type cannot be read + ATTR_INJECT_READS: [ + ProtocolError(), # read device type + ], + }, "1F.111111111111": { ATTR_INJECT_READS: [ b"DS2409", # read device type diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 3123dfb6a5e..999794ec20d 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -219,6 +219,18 @@ }), ]) # --- +# name: test_binary_sensors[16.111111111111] + list([ + ]) +# --- +# name: test_binary_sensors[16.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[16.111111111111].2 + list([ + ]) +# --- # name: test_binary_sensors[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index aa8c914ece5..59ed167197d 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -278,6 +278,18 @@ }), ]) # --- +# name: test_sensors[16.111111111111] + list([ + ]) +# --- +# name: test_sensors[16.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[16.111111111111].2 + list([ + ]) +# --- # name: test_sensors[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 2ac542d203c..8fd1e2aeef6 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -351,6 +351,18 @@ }), ]) # --- +# name: test_switches[16.111111111111] + list([ + ]) +# --- +# name: test_switches[16.111111111111].1 + list([ + ]) +# --- +# name: test_switches[16.111111111111].2 + list([ + ]) +# --- # name: test_switches[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ From b403c9f92e088dcea136cb733aa5d672e5717290 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:26:35 -0500 Subject: [PATCH 020/272] Move thread safety check in area_registry sooner (#116265) It turns out we have custom components that are writing to the area registry using the async APIs from threads. We now catch it at the point async_fire is called. Instead we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. --- homeassistant/helpers/area_registry.py | 11 ++++++-- tests/helpers/test_area_registry.py | 38 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index b39fee9c185..4dba510396f 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -202,6 +202,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): picture: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("async_create") normalized_name = normalize_name(name) if self.async_get_area_by_name(name): @@ -221,7 +222,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): assert area.id is not None self.areas[area.id] = area self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="create", area_id=area.id), ) @@ -230,6 +231,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def async_delete(self, area_id: str) -> None: """Delete area.""" + self.hass.verify_event_loop_thread("async_delete") device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) @@ -237,7 +239,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): del self.areas[area_id] - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="remove", area_id=area_id), ) @@ -266,6 +268,10 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): name=name, picture=picture, ) + # Since updated may be the old or the new and we always fire + # an event even if nothing has changed we cannot use async_fire_internal + # here because we do not know if the thread safety check already + # happened or not in _async_update. self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="update", area_id=area_id), @@ -306,6 +312,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if not new_values: return old + self.hass.verify_event_loop_thread("_async_update") new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] self.async_schedule_save() diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 1ee8a42b6b9..22f1dc8e534 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,5 +1,6 @@ """Tests for the Area Registry.""" +from functools import partial from typing import Any import pytest @@ -491,3 +492,40 @@ async def test_entries_for_label( assert not ar.async_entries_for_label(area_registry, "unknown") assert not ar.async_entries_for_label(area_registry, "") + + +async def test_async_get_or_create_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to create in the wrong thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls async_create from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(area_registry.async_create, "Mock1") + + +async def test_async_update_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to update in the wrong thread.""" + area = area_registry.async_create("Mock1") + with pytest.raises( + RuntimeError, + match="Detected code that calls _async_update from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial(area_registry.async_update, area.id, name="Mock2") + ) + + +async def test_async_delete_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to delete in the wrong thread.""" + area = area_registry.async_create("Mock1") + with pytest.raises( + RuntimeError, + match="Detected code that calls async_delete from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(area_registry.async_delete, area.id) From c65187cbfbd236186aa3cc78f8554b36da649e09 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 25 Apr 2024 21:06:52 +0200 Subject: [PATCH 021/272] Fix climate entity creation when Shelly WallDisplay uses external relay as actuator (#115216) * Fix climate entity creation when Shelly WallDisplay uses external relay as actuator * More comments * Wrap condition into function --------- Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/shelly/climate.py | 6 +++- homeassistant/components/shelly/switch.py | 16 ++++++--- homeassistant/components/shelly/utils.py | 5 +++ tests/components/shelly/test_climate.py | 40 +++++++++++++++++++++- tests/components/shelly/test_switch.py | 1 + 5 files changed, 62 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index b368b38820e..81289bc1a9b 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -132,7 +132,11 @@ def async_setup_rpc_entry( climate_ids = [] for id_ in climate_key_ids: climate_ids.append(id_) - + # There are three configuration scenarios for WallDisplay: + # - relay mode (no thermostat) + # - thermostat mode using the internal relay as an actuator + # - thermostat mode using an external (from another device) relay as + # an actuator if is_rpc_thermostat_internal_actuator(coordinator.device.status): # Wall Display relay is used as the thermostat actuator, # we need to remove a switch entity diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 14fec43c58b..81b16d48ab8 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -43,6 +43,7 @@ from .utils import ( is_block_channel_type_light, is_rpc_channel_type_light, is_rpc_thermostat_internal_actuator, + is_rpc_thermostat_mode, ) @@ -140,12 +141,19 @@ def async_setup_rpc_entry( continue if coordinator.model == MODEL_WALL_DISPLAY: - if not is_rpc_thermostat_internal_actuator(coordinator.device.status): - # Wall Display relay is not used as the thermostat actuator, - # we need to remove a climate entity + # There are three configuration scenarios for WallDisplay: + # - relay mode (no thermostat) + # - thermostat mode using the internal relay as an actuator + # - thermostat mode using an external (from another device) relay as + # an actuator + if not is_rpc_thermostat_mode(id_, coordinator.device.status): + # The device is not in thermostat mode, we need to remove a climate + # entity unique_id = f"{coordinator.mac}-thermostat:{id_}" async_remove_shelly_entity(hass, "climate", unique_id) - else: + elif is_rpc_thermostat_internal_actuator(coordinator.device.status): + # The internal relay is an actuator, skip this ID so as not to create + # a switch entity continue switch_ids.append(id_) diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index ce98e0d5c12..b7cb2f1476a 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -500,3 +500,8 @@ def async_remove_shelly_rpc_entities( if entity_id := entity_reg.async_get_entity_id(domain, DOMAIN, f"{mac}-{key}"): LOGGER.debug("Removing entity: %s", entity_id) entity_reg.async_remove(entity_id) + + +def is_rpc_thermostat_mode(ident: int, status: dict[str, Any]) -> bool: + """Return True if 'thermostat:' is present in the status.""" + return f"thermostat:{ident}" in status diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 9fee3468f11..9946dd7640d 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -25,7 +25,12 @@ from homeassistant.components.climate import ( from homeassistant.components.shelly.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers.device_registry import DeviceRegistry @@ -711,3 +716,36 @@ async def test_wall_display_thermostat_mode( entry = entity_registry.async_get(climate_entity_id) assert entry assert entry.unique_id == "123456789ABC-thermostat:0" + + +async def test_wall_display_thermostat_mode_external_actuator( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Wall Display in thermostat mode with an external actuator.""" + climate_entity_id = "climate.test_name" + switch_entity_id = "switch.test_switch_0" + + new_status = deepcopy(mock_rpc_device.status) + new_status["sys"]["relay_in_thermostat"] = False + monkeypatch.setattr(mock_rpc_device, "status", new_status) + + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # the switch entity should be created + state = hass.states.get(switch_entity_id) + assert state + assert state.state == STATE_ON + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + # the climate entity should be created + state = hass.states.get(climate_entity_id) + assert state + assert state.state == HVACMode.HEAT + assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + + entry = entity_registry.async_get(climate_entity_id) + assert entry + assert entry.unique_id == "123456789ABC-thermostat:0" diff --git a/tests/components/shelly/test_switch.py b/tests/components/shelly/test_switch.py index fe2c4354afc..dd214c8841d 100644 --- a/tests/components/shelly/test_switch.py +++ b/tests/components/shelly/test_switch.py @@ -330,6 +330,7 @@ async def test_wall_display_relay_mode( new_status = deepcopy(mock_rpc_device.status) new_status["sys"]["relay_in_thermostat"] = False + new_status.pop("thermostat:0") monkeypatch.setattr(mock_rpc_device, "status", new_status) await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) From 0eace572c6f879443c261dfd524ed19680273370 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 27 Apr 2024 09:24:11 +0200 Subject: [PATCH 022/272] Don't create event entries for lighting4 rfxtrx devices (#115716) These have no standardized command need to be reworked in the backing library to support exposing as events. Fixes #115545 --- homeassistant/components/rfxtrx/event.py | 6 +++++- tests/components/rfxtrx/test_event.py | 23 +++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rfxtrx/event.py b/homeassistant/components/rfxtrx/event.py index 7e73919aacd..5c3944dc74b 100644 --- a/homeassistant/components/rfxtrx/event.py +++ b/homeassistant/components/rfxtrx/event.py @@ -15,6 +15,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from . import DeviceTuple, RfxtrxEntity, async_setup_platform_entry +from .const import DEVICE_PACKET_TYPE_LIGHTING4 _LOGGER = logging.getLogger(__name__) @@ -27,7 +28,10 @@ async def async_setup_entry( """Set up config entry.""" def _supported(event: RFXtrxEvent) -> bool: - return isinstance(event, (ControlEvent, SensorEvent)) + return ( + isinstance(event, (ControlEvent, SensorEvent)) + and event.device.packettype != DEVICE_PACKET_TYPE_LIGHTING4 + ) def _constructor( event: RFXtrxEvent, diff --git a/tests/components/rfxtrx/test_event.py b/tests/components/rfxtrx/test_event.py index 1a4305d97f6..035949efe3b 100644 --- a/tests/components/rfxtrx/test_event.py +++ b/tests/components/rfxtrx/test_event.py @@ -10,6 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.rfxtrx import get_rfx_object from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from .conftest import setup_rfx_test_cfg @@ -101,3 +102,25 @@ async def test_invalid_event_type( await hass.async_block_till_done() assert hass.states.get("event.arc_c1") == state + + +async def test_ignoring_lighting4(hass: HomeAssistant, rfxtrx) -> None: + """Test with 1 sensor.""" + entry = await setup_rfx_test_cfg( + hass, + devices={ + "0913000022670e013970": { + "data_bits": 4, + "command_on": 0xE, + "command_off": 0x7, + } + }, + ) + + registry = er.async_get(hass) + entries = [ + entry + for entry in registry.entities.get_entries_for_config_entry_id(entry.entry_id) + if entry.domain == Platform.EVENT + ] + assert entries == [] From d6f1d0666c3fec1f47d9f88e0bcd9a36febedcce Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 25 Apr 2024 19:58:13 +0200 Subject: [PATCH 023/272] Update rfxtrx to 0.31.1 (#116125) --- homeassistant/components/rfxtrx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index ec902855f27..bb3701e2e31 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/rfxtrx", "iot_class": "local_push", "loggers": ["RFXtrx"], - "requirements": ["pyRFXtrx==0.31.0"] + "requirements": ["pyRFXtrx==0.31.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee8a074bf6b..b58b5948cad 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1661,7 +1661,7 @@ pyEmby==1.9 pyHik==0.3.2 # homeassistant.components.rfxtrx -pyRFXtrx==0.31.0 +pyRFXtrx==0.31.1 # homeassistant.components.sony_projector pySDCP==1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2eb9a80281f..75eb06c924d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1311,7 +1311,7 @@ pyDuotecno==2024.3.2 pyElectra==1.2.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.31.0 +pyRFXtrx==0.31.1 # homeassistant.components.tibber pyTibber==0.28.2 From f91266908dd3159cad0accfb99a1e14170b7492b Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 25 Apr 2024 19:57:15 +0200 Subject: [PATCH 024/272] Bump pyfibaro to 0.7.8 (#116126) --- homeassistant/components/fibaro/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index bb1558f998b..39850672d06 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.7"] + "requirements": ["pyfibaro==0.7.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index b58b5948cad..1e18b1833c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1818,7 +1818,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.7 +pyfibaro==0.7.8 # homeassistant.components.fido pyfido==2.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75eb06c924d..74205a57b64 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1417,7 +1417,7 @@ pyevilgenius==2.0.0 pyezviz==0.2.1.2 # homeassistant.components.fibaro -pyfibaro==0.7.7 +pyfibaro==0.7.8 # homeassistant.components.fido pyfido==2.1.2 From 74f32cfa90ec66eb8c8dfae596c931e6fc6218db Mon Sep 17 00:00:00 2001 From: On Freund Date: Sat, 27 Apr 2024 10:26:11 +0300 Subject: [PATCH 025/272] Avoid blocking the event loop when unloading Monoprice (#116141) * Avoid blocking the event loop when unloading Monoprice * Code review suggestions --- .../components/monoprice/__init__.py | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py index 57282fb6545..c7683ebedd6 100644 --- a/homeassistant/components/monoprice/__init__.py +++ b/homeassistant/components/monoprice/__init__.py @@ -57,11 +57,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() - hass.data[DOMAIN].pop(entry.entry_id) + if not unload_ok: + return False - return unload_ok + hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENER]() + + def _cleanup(monoprice) -> None: + """Destroy the Monoprice object. + + Destroying the Monoprice closes the serial connection, do it in an executor so the garbage + collection does not block. + """ + del monoprice + + monoprice = hass.data[DOMAIN][entry.entry_id][MONOPRICE_OBJECT] + hass.data[DOMAIN].pop(entry.entry_id) + + await hass.async_add_executor_job(_cleanup, monoprice) + + return True async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: From c8d025f52546d117ba6130615d4d8d6a44df5a39 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Apr 2024 13:02:18 +0200 Subject: [PATCH 026/272] Remove deprecation warnings for relative_time (#116144) * Remove deprecation warnings for relative_time * Update homeassistant/helpers/template.py Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> --------- Co-authored-by: Simon <80467011+sorgfresser@users.noreply.github.com> --- .../components/homeassistant/strings.json | 4 --- homeassistant/helpers/template.py | 26 +++---------------- tests/helpers/test_template.py | 6 +---- 3 files changed, 4 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 5cdd47d8be4..09b2f17c947 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -56,10 +56,6 @@ "config_entry_reauth": { "title": "[%key:common::config_flow::title::reauth%]", "description": "Reauthentication is needed" - }, - "template_function_relative_time_deprecated": { - "title": "The {relative_time} template function is deprecated", - "description": "The {relative_time} template function is deprecated in Home Assistant. Please use the {time_since} or {time_until} template functions instead." } }, "system_health": { diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 335d6842548..ea45ac4e74a 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -59,7 +59,6 @@ from homeassistant.const import ( UnitOfLength, ) from homeassistant.core import ( - DOMAIN as HA_DOMAIN, Context, HomeAssistant, State, @@ -2480,30 +2479,11 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: Make sure date is not in the future, or else it will return None. If the input are not a datetime object the input will be returned unmodified. + + Note: This template function is deprecated in favor of `time_until`, but is still + supported so as not to break old templates. """ - def warn_relative_time_deprecated() -> None: - ir = issue_registry.async_get(hass) - issue_id = "template_function_relative_time_deprecated" - if ir.async_get_issue(HA_DOMAIN, issue_id): - return - issue_registry.async_create_issue( - hass, - HA_DOMAIN, - issue_id, - breaks_in_ha_version="2024.11.0", - is_fixable=False, - severity=issue_registry.IssueSeverity.WARNING, - translation_key=issue_id, - translation_placeholders={ - "relative_time": "relative_time()", - "time_since": "time_since()", - "time_until": "time_until()", - }, - ) - _LOGGER.warning("Template function 'relative_time' is deprecated") - - warn_relative_time_deprecated() if (render_info := _render_info.get()) is not None: render_info.has_time = True diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d134570d119..a241f6b7234 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -31,7 +31,7 @@ from homeassistant.const import ( UnitOfTemperature, UnitOfVolume, ) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant +from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import ( area_registry as ar, @@ -2240,7 +2240,6 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: """Test relative_time method.""" hass.config.set_time_zone("UTC") now = datetime.strptime("2000-01-01 10:00:00 +00:00", "%Y-%m-%d %H:%M:%S %z") - issue_registry = ir.async_get(hass) relative_time_template = ( '{{relative_time(strptime("2000-01-01 09:00:00", "%Y-%m-%d %H:%M:%S"))}}' ) @@ -2250,9 +2249,6 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: hass, ).async_render() assert result == "1 hour" - assert issue_registry.async_get_issue( - HA_DOMAIN, "template_function_relative_time_deprecated" - ) result = template.Template( ( "{{" From 18f1c0c9f3e82b2d62b33f39ad48019ed77081e3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Apr 2024 13:01:41 +0200 Subject: [PATCH 027/272] Fix lying docstring for relative_time template function (#116146) * Fix lying docstring for relative_time template function * Update homeassistant/helpers/template.py Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- homeassistant/helpers/template.py | 3 ++- tests/helpers/test_template.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ea45ac4e74a..c12494ba71b 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2476,7 +2476,8 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: The age can be in second, minute, hour, day, month or year. Only the biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will be returned. - Make sure date is not in the future, or else it will return None. + If the input datetime is in the future, + the input datetime will be returned. If the input are not a datetime object the input will be returned unmodified. diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a241f6b7234..1e2e512cf3d 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -2303,6 +2303,38 @@ def test_relative_time(mock_is_safe, hass: HomeAssistant) -> None: ).async_render() assert result == "string" + # Test behavior when current time is same as the input time + result = template.Template( + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 10:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "0 seconds" + + # Test behavior when the input time is in the future + result = template.Template( + ( + "{{" + " relative_time(" + " strptime(" + ' "2000-01-01 11:00:00 +00:00",' + ' "%Y-%m-%d %H:%M:%S %z"' + " )" + " )" + "}}" + ), + hass, + ).async_render() + assert result == "2000-01-01 11:00:00+00:00" + info = template.Template(relative_time_template, hass).async_render_to_info() assert info.has_time is True From 571c86cb91a48bdc5bcf2fe221598690c21f9bae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 27 Apr 2024 09:26:26 +0200 Subject: [PATCH 028/272] Handle invalid device type in onewire (#116153) * Make device type optional in onewire * Add comment --- homeassistant/components/onewire/binary_sensor.py | 2 +- homeassistant/components/onewire/model.py | 2 +- homeassistant/components/onewire/onewirehub.py | 10 +++++++--- homeassistant/components/onewire/sensor.py | 4 ++-- homeassistant/components/onewire/switch.py | 2 +- tests/components/onewire/const.py | 8 +++++++- .../onewire/snapshots/test_binary_sensor.ambr | 12 ++++++++++++ tests/components/onewire/snapshots/test_sensor.ambr | 12 ++++++++++++ tests/components/onewire/snapshots/test_switch.ambr | 12 ++++++++++++ 9 files changed, 55 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index d2e66609103..3c2ca3529cc 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -117,7 +117,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: device_type = device.type device_info = device.device_info device_sub_type = "std" - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type diff --git a/homeassistant/components/onewire/model.py b/homeassistant/components/onewire/model.py index 9deaca2d121..a59953dcd25 100644 --- a/homeassistant/components/onewire/model.py +++ b/homeassistant/components/onewire/model.py @@ -16,4 +16,4 @@ class OWDeviceDescription: family: str id: str path: str - type: str + type: str | None diff --git a/homeassistant/components/onewire/onewirehub.py b/homeassistant/components/onewire/onewirehub.py index b01cc6ba3d6..2dc617ba039 100644 --- a/homeassistant/components/onewire/onewirehub.py +++ b/homeassistant/components/onewire/onewirehub.py @@ -45,7 +45,7 @@ DEVICE_MANUFACTURER = { _LOGGER = logging.getLogger(__name__) -def _is_known_device(device_family: str, device_type: str) -> bool: +def _is_known_device(device_family: str, device_type: str | None) -> bool: """Check if device family/type is known to the library.""" if device_family in ("7E", "EF"): # EDS or HobbyBoard return device_type in DEVICE_SUPPORT[device_family] @@ -144,11 +144,15 @@ class OneWireHub: return devices - def _get_device_type(self, device_path: str) -> str: + def _get_device_type(self, device_path: str) -> str | None: """Get device model.""" if TYPE_CHECKING: assert self.owproxy - device_type = self.owproxy.read(f"{device_path}type").decode() + try: + device_type = self.owproxy.read(f"{device_path}type").decode() + except protocol.ProtocolError as exc: + _LOGGER.debug("Unable to read `%stype`: %s", device_path, exc) + return None _LOGGER.debug("read `%stype`: %s", device_path, device_type) if device_type == "EDS": device_type = self.owproxy.read(f"{device_path}device_type").decode() diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 46f18842d51..3e43df4dddd 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -377,10 +377,10 @@ def get_entities( device_info = device.device_info device_sub_type = "std" device_path = device.path - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type - elif "7E" in family: + elif device_type and "7E" in family: device_sub_type = "EDS" family = device_type elif "A6" in family: diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 41276218540..94a7d41ab85 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -178,7 +178,7 @@ def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: device_id = device.id device_info = device.device_info device_sub_type = "std" - if "EF" in family: + if device_type and "EF" in family: device_sub_type = "HobbyBoard" family = device_type elif "A6" in family: diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index e5f8ac575e9..a1bab9807d5 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,6 +1,6 @@ """Constants for 1-Wire integration.""" -from pyownet.protocol import Error as ProtocolError +from pyownet.protocol import ProtocolError from homeassistant.components.onewire.const import Platform @@ -58,6 +58,12 @@ MOCK_OWPROXY_DEVICES = { {ATTR_INJECT_READS: b" 248125"}, ], }, + "16.111111111111": { + # Test case for issue #115984, where the device type cannot be read + ATTR_INJECT_READS: [ + ProtocolError(), # read device type + ], + }, "1F.111111111111": { ATTR_INJECT_READS: [ b"DS2409", # read device type diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 3123dfb6a5e..999794ec20d 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -219,6 +219,18 @@ }), ]) # --- +# name: test_binary_sensors[16.111111111111] + list([ + ]) +# --- +# name: test_binary_sensors[16.111111111111].1 + list([ + ]) +# --- +# name: test_binary_sensors[16.111111111111].2 + list([ + ]) +# --- # name: test_binary_sensors[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index aa8c914ece5..59ed167197d 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -278,6 +278,18 @@ }), ]) # --- +# name: test_sensors[16.111111111111] + list([ + ]) +# --- +# name: test_sensors[16.111111111111].1 + list([ + ]) +# --- +# name: test_sensors[16.111111111111].2 + list([ + ]) +# --- # name: test_sensors[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index 2ac542d203c..8fd1e2aeef6 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -351,6 +351,18 @@ }), ]) # --- +# name: test_switches[16.111111111111] + list([ + ]) +# --- +# name: test_switches[16.111111111111].1 + list([ + ]) +# --- +# name: test_switches[16.111111111111].2 + list([ + ]) +# --- # name: test_switches[1D.111111111111] list([ DeviceRegistryEntrySnapshot({ From 0b74f02c4e9700443c5450fd1f1ea28d46dd819c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Apr 2024 10:48:32 +0200 Subject: [PATCH 029/272] Fix language in strict connection guard page (#116154) --- homeassistant/components/http/strict_connection_guard_page.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/http/strict_connection_guard_page.html b/homeassistant/components/http/strict_connection_guard_page.html index 86ea8e00e90..8567e500c9d 100644 --- a/homeassistant/components/http/strict_connection_guard_page.html +++ b/homeassistant/components/http/strict_connection_guard_page.html @@ -123,7 +123,7 @@

You need access

- This device is not known on + This device is not known to Home Assistant.

From 29ab68fd24f82955dd0a1bc4f66631854b94ff82 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 25 Apr 2024 11:21:19 +0200 Subject: [PATCH 030/272] Update unlocked icon for locks (#116157) --- homeassistant/components/lock/icons.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lock/icons.json b/homeassistant/components/lock/icons.json index 1bf48f2ab40..0ce2e70d372 100644 --- a/homeassistant/components/lock/icons.json +++ b/homeassistant/components/lock/icons.json @@ -5,7 +5,7 @@ "state": { "jammed": "mdi:lock-alert", "locking": "mdi:lock-clock", - "unlocked": "mdi:lock-open", + "unlocked": "mdi:lock-open-variant", "unlocking": "mdi:lock-clock" } } @@ -13,6 +13,6 @@ "services": { "lock": "mdi:lock", "open": "mdi:door-open", - "unlock": "mdi:lock-open" + "unlock": "mdi:lock-open-variant" } } From 4612f18186f5ccf4f5b3c44636515f31fc133230 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 08:38:24 +0200 Subject: [PATCH 031/272] Remove early return when validating entity registry items (#116160) --- homeassistant/helpers/entity_registry.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 4e77df49ea6..436fc5a18de 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -636,7 +636,6 @@ def _validate_item( unique_id, report_issue, ) - return if ( disabled_by and disabled_by is not UNDEFINED From 12bce5451ee7fe32c4b560a4fe5a3a268ed04629 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Apr 2024 18:15:57 +0200 Subject: [PATCH 032/272] Revert orjson to 3.9.15 due to segmentation faults (#116168) --- 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 b88f2aefffa..aa29713a849 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ ifaddr==0.2.0 Jinja2==3.1.3 lru-dict==1.3.0 mutagen==1.47.0 -orjson==3.10.1 +orjson==3.9.15 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.3.0 diff --git a/pyproject.toml b/pyproject.toml index 34c7d648795..0427019a29e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,7 +53,7 @@ dependencies = [ "cryptography==42.0.5", "Pillow==10.3.0", "pyOpenSSL==24.1.0", - "orjson==3.10.1", + "orjson==3.9.15", "packaging>=23.1", "pip>=21.3.1", "psutil-home-assistant==0.0.1", diff --git a/requirements.txt b/requirements.txt index 34ee8237921..44c60aec07a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,7 +28,7 @@ PyJWT==2.8.0 cryptography==42.0.5 Pillow==10.3.0 pyOpenSSL==24.1.0 -orjson==3.10.1 +orjson==3.9.15 packaging>=23.1 pip>=21.3.1 psutil-home-assistant==0.0.1 From 5ac8488d2a0473a667f1191d87f8edea2f4e1541 Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Thu, 25 Apr 2024 13:35:29 -0500 Subject: [PATCH 033/272] Update Ollama model names list (#116172) --- homeassistant/components/ollama/const.py | 145 ++++++++++++----------- 1 file changed, 78 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/ollama/const.py b/homeassistant/components/ollama/const.py index 853370066dc..e25ae1f0877 100644 --- a/homeassistant/components/ollama/const.py +++ b/homeassistant/components/ollama/const.py @@ -81,75 +81,86 @@ DEFAULT_MAX_HISTORY = 20 MAX_HISTORY_SECONDS = 60 * 60 # 1 hour MODEL_NAMES = [ # https://ollama.com/library - "gemma", - "llama2", - "mistral", - "mixtral", - "llava", - "neural-chat", - "codellama", - "dolphin-mixtral", - "qwen", - "llama2-uncensored", - "mistral-openorca", - "deepseek-coder", - "nous-hermes2", - "phi", - "orca-mini", - "dolphin-mistral", - "wizard-vicuna-uncensored", - "vicuna", - "tinydolphin", - "llama2-chinese", - "nomic-embed-text", - "openhermes", - "zephyr", - "tinyllama", - "openchat", - "wizardcoder", - "starcoder", - "phind-codellama", - "starcoder2", - "yi", - "orca2", - "falcon", - "wizard-math", - "dolphin-phi", - "starling-lm", - "nous-hermes", - "stable-code", - "medllama2", - "bakllava", - "codeup", - "wizardlm-uncensored", - "solar", - "everythinglm", - "sqlcoder", - "dolphincoder", - "nous-hermes2-mixtral", - "stable-beluga", - "yarn-mistral", - "stablelm2", - "samantha-mistral", - "meditron", - "stablelm-zephyr", - "magicoder", - "yarn-llama2", - "llama-pro", - "deepseek-llm", - "wizard-vicuna", - "codebooga", - "mistrallite", - "all-minilm", - "nexusraven", - "open-orca-platypus2", - "goliath", - "notux", - "megadolphin", "alfred", - "xwinlm", - "wizardlm", + "all-minilm", + "bakllava", + "codebooga", + "codegemma", + "codellama", + "codeqwen", + "codeup", + "command-r", + "command-r-plus", + "dbrx", + "deepseek-coder", + "deepseek-llm", + "dolphin-llama3", + "dolphin-mistral", + "dolphin-mixtral", + "dolphin-phi", + "dolphincoder", "duckdb-nsql", + "everythinglm", + "falcon", + "gemma", + "goliath", + "llama-pro", + "llama2", + "llama2-chinese", + "llama2-uncensored", + "llama3", + "llava", + "magicoder", + "meditron", + "medllama2", + "megadolphin", + "mistral", + "mistral-openorca", + "mistrallite", + "mixtral", + "mxbai-embed-large", + "neural-chat", + "nexusraven", + "nomic-embed-text", "notus", + "notux", + "nous-hermes", + "nous-hermes2", + "nous-hermes2-mixtral", + "open-orca-platypus2", + "openchat", + "openhermes", + "orca-mini", + "orca2", + "phi", + "phi3", + "phind-codellama", + "qwen", + "samantha-mistral", + "snowflake-arctic-embed", + "solar", + "sqlcoder", + "stable-beluga", + "stable-code", + "stablelm-zephyr", + "stablelm2", + "starcoder", + "starcoder2", + "starling-lm", + "tinydolphin", + "tinyllama", + "vicuna", + "wizard-math", + "wizard-vicuna", + "wizard-vicuna-uncensored", + "wizardcoder", + "wizardlm", + "wizardlm-uncensored", + "wizardlm2", + "xwinlm", + "yarn-llama2", + "yarn-mistral", + "yi", + "zephyr", ] DEFAULT_MODEL = "llama2:latest" From e0cc9198aa105ae68d5f1a2efc50c113991b00bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 25 Apr 2024 17:32:42 +0200 Subject: [PATCH 034/272] Revert "Return specific group state if there is one" (#116176) Revert "Return specific group state if there is one (#115866)" This reverts commit 350ca48d4c10b2105e1e3513da7137498dd6ad83. --- homeassistant/components/group/entity.py | 95 ++++------------------ homeassistant/components/group/registry.py | 14 +--- tests/components/group/test_init.py | 24 +----- 3 files changed, 24 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/group/entity.py b/homeassistant/components/group/entity.py index 5ac913dde8d..a8fd9027984 100644 --- a/homeassistant/components/group/entity.py +++ b/homeassistant/components/group/entity.py @@ -8,7 +8,7 @@ from collections.abc import Callable, Collection, Mapping import logging from typing import Any -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_ON from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -131,9 +131,6 @@ class Group(Entity): _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_ORDER, ATTR_AUTO}) _attr_should_poll = False - # In case there is only one active domain we use specific ON or OFF - # values, if all ON or OFF states are equal - single_active_domain: str | None tracking: tuple[str, ...] trackable: tuple[str, ...] @@ -290,7 +287,6 @@ class Group(Entity): if not entity_ids: self.tracking = () self.trackable = () - self.single_active_domain = None return registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] @@ -298,22 +294,12 @@ class Group(Entity): tracking: list[str] = [] trackable: list[str] = [] - self.single_active_domain = None - multiple_domains: bool = False for ent_id in entity_ids: ent_id_lower = ent_id.lower() domain = split_entity_id(ent_id_lower)[0] tracking.append(ent_id_lower) - if domain in excluded_domains: - continue - - trackable.append(ent_id_lower) - - if not multiple_domains and self.single_active_domain is None: - self.single_active_domain = domain - if self.single_active_domain != domain: - multiple_domains = True - self.single_active_domain = None + if domain not in excluded_domains: + trackable.append(ent_id_lower) self.trackable = tuple(trackable) self.tracking = tuple(tracking) @@ -409,36 +395,10 @@ class Group(Entity): self._on_off[entity_id] = state in registry.on_off_mapping else: entity_on_state = registry.on_states_by_domain[domain] - self._on_states.update(entity_on_state) + if domain in registry.on_states_by_domain: + self._on_states.update(entity_on_state) self._on_off[entity_id] = state in entity_on_state - def _detect_specific_on_off_state(self, group_is_on: bool) -> set[str]: - """Check if a specific ON or OFF state is possible.""" - # In case the group contains entities of the same domain with the same ON - # or an OFF state (one or more domains), we want to use that specific state. - # If we have more then one ON or OFF state we default to STATE_ON or STATE_OFF. - registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] - active_on_states: set[str] = set() - active_off_states: set[str] = set() - for entity_id in self.trackable: - if (state := self.hass.states.get(entity_id)) is None: - continue - current_state = state.state - if ( - group_is_on - and (domain_on_states := registry.on_states_by_domain.get(state.domain)) - and current_state in domain_on_states - ): - active_on_states.add(current_state) - # If we have more than one on state, the group state - # will result in STATE_ON and we can stop checking - if len(active_on_states) > 1: - break - elif current_state in registry.off_on_mapping: - active_off_states.add(current_state) - - return active_on_states if group_is_on else active_off_states - @callback def _async_update_group_state(self, tr_state: State | None = None) -> None: """Update group state. @@ -465,48 +425,27 @@ class Group(Entity): elif tr_state.attributes.get(ATTR_ASSUMED_STATE): self._assumed_state = True - # If we do not have an on state for any domains - # we use None (which will be STATE_UNKNOWN) - if (num_on_states := len(self._on_states)) == 0: - self._state = None - return - - group_is_on = self.mode(self._on_off.values()) - + num_on_states = len(self._on_states) # If all the entity domains we are tracking # have the same on state we use this state # and its hass.data[REG_KEY].on_off_mapping to off if num_on_states == 1: - on_state = next(iter(self._on_states)) + on_state = list(self._on_states)[0] + # If we do not have an on state for any domains + # we use None (which will be STATE_UNKNOWN) + elif num_on_states == 0: + self._state = None + return # If the entity domains have more than one - # on state, we use STATE_ON/STATE_OFF, unless there is - # only one specific `on` state in use for one specific domain - elif self.single_active_domain and num_on_states: - active_on_states = self._detect_specific_on_off_state(True) - on_state = ( - list(active_on_states)[0] if len(active_on_states) == 1 else STATE_ON - ) - elif group_is_on: + # on state, we use STATE_ON/STATE_OFF + else: on_state = STATE_ON + group_is_on = self.mode(self._on_off.values()) if group_is_on: self._state = on_state - return - - registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] - if ( - active_domain := self.single_active_domain - ) and active_domain in registry.off_state_by_domain: - # If there is only one domain used, - # then we return the off state for that domain.s - self._state = registry.off_state_by_domain[active_domain] else: - active_off_states = self._detect_specific_on_off_state(False) - # If there is one off state in use then we return that specific state, - # also if there a multiple domains involved, e.g. - # person and device_tracker, with a shared state. - self._state = ( - list(active_off_states)[0] if len(active_off_states) == 1 else STATE_OFF - ) + registry: GroupIntegrationRegistry = self.hass.data[REG_KEY] + self._state = registry.on_off_mapping[on_state] def async_get_component(hass: HomeAssistant) -> EntityComponent[Group]: diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 474448db68a..6cdb929d60c 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -49,12 +49,9 @@ class GroupIntegrationRegistry: def __init__(self) -> None: """Imitialize registry.""" - self.on_off_mapping: dict[str, dict[str | None, str]] = { - STATE_ON: {None: STATE_OFF} - } + self.on_off_mapping: dict[str, str] = {STATE_ON: STATE_OFF} self.off_on_mapping: dict[str, str] = {STATE_OFF: STATE_ON} self.on_states_by_domain: dict[str, set[str]] = {} - self.off_state_by_domain: dict[str, str] = {} self.exclude_domains: set[str] = set() def exclude_domain(self) -> None: @@ -63,14 +60,11 @@ class GroupIntegrationRegistry: def on_off_states(self, on_states: set, off_state: str) -> None: """Register on and off states for the current domain.""" - domain = current_domain.get() for on_state in on_states: if on_state not in self.on_off_mapping: - self.on_off_mapping[on_state] = {domain: off_state} - else: - self.on_off_mapping[on_state][domain] = off_state + self.on_off_mapping[on_state] = off_state + if len(on_states) == 1 and off_state not in self.off_on_mapping: self.off_on_mapping[off_state] = list(on_states)[0] - self.on_states_by_domain[domain] = set(on_states) - self.off_state_by_domain[domain] = off_state + self.on_states_by_domain[current_domain.get()] = set(on_states) diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index b9cdfcb1590..d3f2747933e 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -9,7 +9,7 @@ from unittest.mock import patch import pytest -from homeassistant.components import group, vacuum +from homeassistant.components import group from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, @@ -659,24 +659,6 @@ async def test_is_on(hass: HomeAssistant) -> None: (STATE_ON, True), (STATE_OFF, False), ), - ( - ("vacuum", "vacuum"), - # Cleaning is the only on state - (vacuum.STATE_DOCKED, vacuum.STATE_CLEANING), - # Returning is the only on state - (vacuum.STATE_RETURNING, vacuum.STATE_PAUSED), - (vacuum.STATE_CLEANING, True), - (vacuum.STATE_RETURNING, True), - ), - ( - ("vacuum", "vacuum"), - # Multiple on states, so group state will be STATE_ON - (vacuum.STATE_RETURNING, vacuum.STATE_CLEANING), - # Only off states, so group state will be off - (vacuum.STATE_PAUSED, vacuum.STATE_IDLE), - (STATE_ON, True), - (STATE_OFF, False), - ), ], ) async def test_is_on_and_state_mixed_domains( @@ -1238,7 +1220,7 @@ async def test_group_climate_all_cool(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert hass.states.get("group.group_zero").state == "cool" + assert hass.states.get("group.group_zero").state == STATE_ON async def test_group_climate_all_off(hass: HomeAssistant) -> None: @@ -1352,7 +1334,7 @@ async def test_group_vacuum_on(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert hass.states.get("group.group_zero").state == "cleaning" + assert hass.states.get("group.group_zero").state == STATE_ON async def test_device_tracker_not_home(hass: HomeAssistant) -> None: From 1defd18cf56cec25f52e96719f0eccde1929f54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Apr 2024 12:56:33 -0500 Subject: [PATCH 035/272] Bump govee-ble to 0.31.2 (#116177) changelog: https://github.com/Bluetooth-Devices/govee-ble/compare/v0.31.0...v0.31.2 Fixes some unrelated BLE devices being detected as a GVH5106 --- homeassistant/components/govee_ble/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/govee_ble/manifest.json b/homeassistant/components/govee_ble/manifest.json index 64feedc44c1..98b802f8233 100644 --- a/homeassistant/components/govee_ble/manifest.json +++ b/homeassistant/components/govee_ble/manifest.json @@ -90,5 +90,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/govee_ble", "iot_class": "local_push", - "requirements": ["govee-ble==0.31.0"] + "requirements": ["govee-ble==0.31.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1e18b1833c0..46d842fb7d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -980,7 +980,7 @@ goslide-api==0.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.31.0 +govee-ble==0.31.2 # homeassistant.components.govee_light_local govee-local-api==1.4.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74205a57b64..c0ddfe00a17 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -803,7 +803,7 @@ googlemaps==2.5.1 gotailwind==0.2.2 # homeassistant.components.govee_ble -govee-ble==0.31.0 +govee-ble==0.31.2 # homeassistant.components.govee_light_local govee-local-api==1.4.4 From 7cabb04bc9163f34a4db75f93e058d7f8fe00775 Mon Sep 17 00:00:00 2001 From: On Freund Date: Thu, 25 Apr 2024 20:43:31 +0300 Subject: [PATCH 036/272] Bump pyrisco to 0.6.1 (#116182) --- homeassistant/components/risco/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/risco/manifest.json b/homeassistant/components/risco/manifest.json index 4c590b95e52..22e73a10d6d 100644 --- a/homeassistant/components/risco/manifest.json +++ b/homeassistant/components/risco/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["pyrisco"], "quality_scale": "platinum", - "requirements": ["pyrisco==0.6.0"] + "requirements": ["pyrisco==0.6.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 46d842fb7d1..bb5fbd528bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2093,7 +2093,7 @@ pyrecswitch==1.0.2 pyrepetierng==0.1.0 # homeassistant.components.risco -pyrisco==0.6.0 +pyrisco==0.6.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0ddfe00a17..4c6f5d590e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1635,7 +1635,7 @@ pyqwikswitch==0.93 pyrainbird==4.0.2 # homeassistant.components.risco -pyrisco==0.6.0 +pyrisco==0.6.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.6 From 8ac6593b5392d9740552d00b5542e55f72731e2f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 25 Apr 2024 14:26:11 -0400 Subject: [PATCH 037/272] Make Roborock listener update thread safe (#116184) Co-authored-by: J. Nick Koston --- homeassistant/components/roborock/device.py | 2 +- tests/components/roborock/test_sensor.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 69384d6e23a..6450d849859 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -137,4 +137,4 @@ class RoborockCoordinatedEntity( else: self.coordinator.roborock_device_info.props.consumable = value self.coordinator.data = self.coordinator.roborock_device_info.props - self.async_write_ha_state() + self.schedule_update_ha_state() diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 23d16f643b2..88ed6e1098c 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -89,6 +89,7 @@ async def test_listener_update( ) ] ) + await hass.async_block_till_done() assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( FILTER_REPLACE_TIME - 743 ) From 63ef52a312ec77917647444362ea5e39b5b8c250 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Apr 2024 14:07:07 -0500 Subject: [PATCH 038/272] Fix smartthings doing I/O in the event loop to import platforms (#116190) --- homeassistant/components/smartthings/__init__.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 8136806cd0b..9bfa11d3293 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -28,6 +28,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_loaded_integration +from homeassistant.setup import SetupPhases, async_pause_setup from .config_flow import SmartThingsFlowHandler # noqa: F401 from .const import ( @@ -170,7 +171,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Setup device broker - broker = DeviceBroker(hass, entry, token, smart_app, devices, scenes) + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PLATFORMS): + # DeviceBroker has a side effect of importing platform + # modules when its created. In the future this should be + # refactored to not do this. + broker = await hass.async_add_import_executor_job( + DeviceBroker, hass, entry, token, smart_app, devices, scenes + ) broker.connect() hass.data[DOMAIN][DATA_BROKERS][entry.entry_id] = broker From a9b9d7f566807f8cb170234b8b007a36bc226182 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 25 Apr 2024 21:20:24 +0200 Subject: [PATCH 039/272] Fix flaky traccar_server tests (#116191) --- .../components/traccar_server/diagnostics.py | 4 +- .../snapshots/test_diagnostics.ambr | 190 +++++++++--------- .../traccar_server/test_diagnostics.py | 14 +- 3 files changed, 110 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/traccar_server/diagnostics.py b/homeassistant/components/traccar_server/diagnostics.py index 80dc7a9c7cd..68f1e4fca8a 100644 --- a/homeassistant/components/traccar_server/diagnostics.py +++ b/homeassistant/components/traccar_server/diagnostics.py @@ -57,7 +57,7 @@ async def async_get_config_entry_diagnostics( "coordinator_data": coordinator.data, "entities": [ { - "enity_id": entity.entity_id, + "entity_id": entity.entity_id, "disabled": entity.disabled, "unit_of_measurement": entity.unit_of_measurement, "state": _entity_state(hass, entity, coordinator), @@ -92,7 +92,7 @@ async def async_get_device_diagnostics( "coordinator_data": coordinator.data, "entities": [ { - "enity_id": entity.entity_id, + "entity_id": entity.entity_id, "disabled": entity.disabled, "unit_of_measurement": entity.unit_of_measurement, "state": _entity_state(hass, entity, coordinator), diff --git a/tests/components/traccar_server/snapshots/test_diagnostics.ambr b/tests/components/traccar_server/snapshots/test_diagnostics.ambr index 89a6416c303..39e67db8df7 100644 --- a/tests/components/traccar_server/snapshots/test_diagnostics.ambr +++ b/tests/components/traccar_server/snapshots/test_diagnostics.ambr @@ -73,7 +73,30 @@ 'entities': list([ dict({ 'disabled': False, - 'enity_id': 'device_tracker.x_wing', + 'entity_id': 'binary_sensor.x_wing_motion', + 'state': dict({ + 'attributes': dict({ + 'device_class': 'motion', + 'friendly_name': 'X-Wing Motion', + }), + 'state': 'off', + }), + 'unit_of_measurement': None, + }), + dict({ + 'disabled': False, + 'entity_id': 'binary_sensor.x_wing_status', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'X-Wing Status', + }), + 'state': 'on', + }), + 'unit_of_measurement': None, + }), + dict({ + 'disabled': False, + 'entity_id': 'device_tracker.x_wing', 'state': dict({ 'attributes': dict({ 'category': 'starfighter', @@ -92,30 +115,31 @@ }), dict({ 'disabled': False, - 'enity_id': 'binary_sensor.x_wing_motion', + 'entity_id': 'sensor.x_wing_address', 'state': dict({ 'attributes': dict({ - 'device_class': 'motion', - 'friendly_name': 'X-Wing Motion', + 'friendly_name': 'X-Wing Address', }), - 'state': 'off', + 'state': '**REDACTED**', }), 'unit_of_measurement': None, }), dict({ 'disabled': False, - 'enity_id': 'binary_sensor.x_wing_status', + 'entity_id': 'sensor.x_wing_altitude', 'state': dict({ 'attributes': dict({ - 'friendly_name': 'X-Wing Status', + 'friendly_name': 'X-Wing Altitude', + 'state_class': 'measurement', + 'unit_of_measurement': 'm', }), - 'state': 'on', + 'state': '546841384638', }), - 'unit_of_measurement': None, + 'unit_of_measurement': 'm', }), dict({ 'disabled': False, - 'enity_id': 'sensor.x_wing_battery', + 'entity_id': 'sensor.x_wing_battery', 'state': dict({ 'attributes': dict({ 'device_class': 'battery', @@ -129,7 +153,18 @@ }), dict({ 'disabled': False, - 'enity_id': 'sensor.x_wing_speed', + 'entity_id': 'sensor.x_wing_geofence', + 'state': dict({ + 'attributes': dict({ + 'friendly_name': 'X-Wing Geofence', + }), + 'state': 'Tatooine', + }), + 'unit_of_measurement': None, + }), + dict({ + 'disabled': False, + 'entity_id': 'sensor.x_wing_speed', 'state': dict({ 'attributes': dict({ 'device_class': 'speed', @@ -141,41 +176,6 @@ }), 'unit_of_measurement': 'kn', }), - dict({ - 'disabled': False, - 'enity_id': 'sensor.x_wing_altitude', - 'state': dict({ - 'attributes': dict({ - 'friendly_name': 'X-Wing Altitude', - 'state_class': 'measurement', - 'unit_of_measurement': 'm', - }), - 'state': '546841384638', - }), - 'unit_of_measurement': 'm', - }), - dict({ - 'disabled': False, - 'enity_id': 'sensor.x_wing_address', - 'state': dict({ - 'attributes': dict({ - 'friendly_name': 'X-Wing Address', - }), - 'state': '**REDACTED**', - }), - 'unit_of_measurement': None, - }), - dict({ - 'disabled': False, - 'enity_id': 'sensor.x_wing_geofence', - 'state': dict({ - 'attributes': dict({ - 'friendly_name': 'X-Wing Geofence', - }), - 'state': 'Tatooine', - }), - 'unit_of_measurement': None, - }), ]), 'subscription_status': 'disconnected', }) @@ -254,51 +254,51 @@ 'entities': list([ dict({ 'disabled': True, - 'enity_id': 'binary_sensor.x_wing_motion', + 'entity_id': 'binary_sensor.x_wing_motion', 'state': None, 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'binary_sensor.x_wing_status', + 'entity_id': 'binary_sensor.x_wing_status', 'state': None, 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'sensor.x_wing_battery', + 'entity_id': 'device_tracker.x_wing', 'state': None, - 'unit_of_measurement': '%', + 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'sensor.x_wing_speed', + 'entity_id': 'sensor.x_wing_address', 'state': None, - 'unit_of_measurement': 'kn', + 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'sensor.x_wing_altitude', + 'entity_id': 'sensor.x_wing_altitude', 'state': None, 'unit_of_measurement': 'm', }), dict({ 'disabled': True, - 'enity_id': 'sensor.x_wing_address', + 'entity_id': 'sensor.x_wing_battery', + 'state': None, + 'unit_of_measurement': '%', + }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_geofence', 'state': None, 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'sensor.x_wing_geofence', + 'entity_id': 'sensor.x_wing_speed', 'state': None, - 'unit_of_measurement': None, - }), - dict({ - 'disabled': True, - 'enity_id': 'device_tracker.x_wing', - 'state': None, - 'unit_of_measurement': None, + 'unit_of_measurement': 'kn', }), ]), 'subscription_status': 'disconnected', @@ -378,49 +378,19 @@ 'entities': list([ dict({ 'disabled': True, - 'enity_id': 'binary_sensor.x_wing_motion', + 'entity_id': 'binary_sensor.x_wing_motion', 'state': None, 'unit_of_measurement': None, }), dict({ 'disabled': True, - 'enity_id': 'binary_sensor.x_wing_status', - 'state': None, - 'unit_of_measurement': None, - }), - dict({ - 'disabled': True, - 'enity_id': 'sensor.x_wing_battery', - 'state': None, - 'unit_of_measurement': '%', - }), - dict({ - 'disabled': True, - 'enity_id': 'sensor.x_wing_speed', - 'state': None, - 'unit_of_measurement': 'kn', - }), - dict({ - 'disabled': True, - 'enity_id': 'sensor.x_wing_altitude', - 'state': None, - 'unit_of_measurement': 'm', - }), - dict({ - 'disabled': True, - 'enity_id': 'sensor.x_wing_address', - 'state': None, - 'unit_of_measurement': None, - }), - dict({ - 'disabled': True, - 'enity_id': 'sensor.x_wing_geofence', + 'entity_id': 'binary_sensor.x_wing_status', 'state': None, 'unit_of_measurement': None, }), dict({ 'disabled': False, - 'enity_id': 'device_tracker.x_wing', + 'entity_id': 'device_tracker.x_wing', 'state': dict({ 'attributes': dict({ 'category': 'starfighter', @@ -437,6 +407,36 @@ }), 'unit_of_measurement': None, }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_address', + 'state': None, + 'unit_of_measurement': None, + }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_altitude', + 'state': None, + 'unit_of_measurement': 'm', + }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_battery', + 'state': None, + 'unit_of_measurement': '%', + }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_geofence', + 'state': None, + 'unit_of_measurement': None, + }), + dict({ + 'disabled': True, + 'entity_id': 'sensor.x_wing_speed', + 'state': None, + 'unit_of_measurement': 'kn', + }), ]), 'subscription_status': 'disconnected', }) diff --git a/tests/components/traccar_server/test_diagnostics.py b/tests/components/traccar_server/test_diagnostics.py index 493f0ae92d1..9019cd0ebf1 100644 --- a/tests/components/traccar_server/test_diagnostics.py +++ b/tests/components/traccar_server/test_diagnostics.py @@ -33,6 +33,10 @@ async def test_entry_diagnostics( hass_client, mock_config_entry, ) + # Sort the list of entities + result["entities"] = sorted( + result["entities"], key=lambda entity: entity["entity_id"] + ) assert result == snapshot(name="entry") @@ -64,13 +68,17 @@ async def test_device_diagnostics( device_id=device.id, include_disabled_entities=True, ) - # Enable all entitits to show everything in snapshots + # Enable all entities to show everything in snapshots for entity in entities: entity_registry.async_update_entity(entity.entity_id, disabled_by=None) result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device=device ) + # Sort the list of entities + result["entities"] = sorted( + result["entities"], key=lambda entity: entity["entity_id"] + ) assert result == snapshot(name=device.name) @@ -110,5 +118,9 @@ async def test_device_diagnostics_with_disabled_entity( result = await get_diagnostics_for_device( hass, hass_client, mock_config_entry, device=device ) + # Sort the list of entities + result["entities"] = sorted( + result["entities"], key=lambda entity: entity["entity_id"] + ) assert result == snapshot(name=device.name) From 9f84c38f081ba22670aab5ba1d839ba106575ce5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 25 Apr 2024 15:37:38 -0500 Subject: [PATCH 040/272] Bump bluetooth-auto-recovery to 1.4.2 (#116192) --- 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 f6adcbed7d8..ed1e11d8ddd 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "bleak==0.21.1", "bleak-retry-connector==3.5.0", "bluetooth-adapters==0.19.0", - "bluetooth-auto-recovery==1.4.1", + "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", "habluetooth==2.8.0" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index aa29713a849..442db45e714 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -18,7 +18,7 @@ bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 bluetooth-adapters==0.19.0 -bluetooth-auto-recovery==1.4.1 +bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 certifi>=2021.5.30 diff --git a/requirements_all.txt b/requirements_all.txt index bb5fbd528bf..6ab10019d78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -582,7 +582,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.19.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.1 +bluetooth-auto-recovery==1.4.2 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c6f5d590e9..bc69a55c955 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -497,7 +497,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.19.0 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.4.1 +bluetooth-auto-recovery==1.4.2 # homeassistant.components.bluetooth # homeassistant.components.ld2410_ble From 1be5249269b0699a7b9009db3f73b148876b1659 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 05:00:07 +0200 Subject: [PATCH 041/272] Reduce scope of bootstrap test fixture to module (#116195) --- tests/test_bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 2e35e4ffddb..96caf5d10c8 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -44,7 +44,7 @@ async def apply_stop_hass(stop_hass: None) -> None: """Make sure all hass are stopped.""" -@pytest.fixture(scope="session", autouse=True) +@pytest.fixture(scope="module", autouse=True) def mock_http_start_stop() -> Generator[None, None, None]: """Mock HTTP start and stop.""" with ( From 8f02ed4bf3a9699b710a44ae3eee5c6dd7c150e5 Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Fri, 26 Apr 2024 23:44:13 +1000 Subject: [PATCH 042/272] Breakfix to handle null value in Teslemetry (#116206) * Fixes * Remove unused test --- 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 75794c7cdec..be34386a508 100644 --- a/homeassistant/components/teslemetry/coordinator.py +++ b/homeassistant/components/teslemetry/coordinator.py @@ -119,7 +119,7 @@ class TeslemetryEnergyDataCoordinator(TeslemetryDataCoordinator): # Convert Wall Connectors from array to dict data["response"]["wall_connectors"] = { - wc["din"]: wc for wc in data["response"].get("wall_connectors", []) + wc["din"]: wc for wc in (data["response"].get("wall_connectors") or []) } return data["response"] From 5fb08e8b256b3ba486340bf34b5e7028df14a6e0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 26 Apr 2024 12:28:40 +0200 Subject: [PATCH 043/272] Restore default timezone after electric_kiwi sensor tests (#116217) --- tests/components/electric_kiwi/conftest.py | 3 --- tests/components/electric_kiwi/test_sensor.py | 19 ++++++++++++++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 8819b1e134d..8052ae5e129 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Generator from time import time from unittest.mock import AsyncMock, patch -import zoneinfo from electrickiwi_api.model import AccountBalance, Hop, HopIntervals import pytest @@ -24,8 +23,6 @@ CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" -TZ_NAME = "Pacific/Auckland" -TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) YieldFixture = Generator[AsyncMock, None, None] ComponentSetup = Callable[[], Awaitable[bool]] diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py index f91e4d9c58c..a247497b263 100644 --- a/tests/components/electric_kiwi/test_sensor.py +++ b/tests/components/electric_kiwi/test_sensor.py @@ -2,6 +2,7 @@ from datetime import UTC, datetime from unittest.mock import AsyncMock, Mock +import zoneinfo from freezegun import freeze_time import pytest @@ -19,10 +20,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.util.dt as dt_util -from .conftest import TIMEZONE, ComponentSetup, YieldFixture +from .conftest import ComponentSetup, YieldFixture from tests.common import MockConfigEntry +DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE +TEST_TZ_NAME = "Pacific/Auckland" +TEST_TIMEZONE = zoneinfo.ZoneInfo(TEST_TZ_NAME) + + +@pytest.fixture(autouse=True) +def restore_timezone(): + """Restore default timezone.""" + yield + + dt_util.set_default_time_zone(DEFAULT_TIME_ZONE) + @pytest.mark.parametrize( ("sensor", "sensor_state"), @@ -124,8 +137,8 @@ async def test_check_and_move_time(ek_api: AsyncMock) -> None: """Test correct time is returned depending on time of day.""" hop = await ek_api(Mock()).get_hop() - test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TIMEZONE) - dt_util.set_default_time_zone(TIMEZONE) + test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TEST_TIMEZONE) + dt_util.set_default_time_zone(TEST_TIMEZONE) with freeze_time(test_time): value = _check_and_move_time(hop, "4:00 PM") From 2861ac4ac9a0f21c02856c297ec46e624f20ad59 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 26 Apr 2024 11:22:04 +0200 Subject: [PATCH 044/272] Use None as default value for strict connection cloud store (#116219) --- homeassistant/components/cloud/prefs.py | 15 +++++++++------ tests/components/cloud/test_prefs.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 9fce615128b..72207513ca9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -365,13 +365,16 @@ class CloudPreferences: @property def strict_connection(self) -> http.const.StrictConnectionMode: """Return the strict connection mode.""" - mode = self._prefs.get( - PREF_STRICT_CONNECTION, http.const.StrictConnectionMode.DISABLED - ) + mode = self._prefs.get(PREF_STRICT_CONNECTION) - if not isinstance(mode, http.const.StrictConnectionMode): + if mode is None: + # Set to default value + # We store None in the store as the default value to detect if the user has changed the + # value or not. + mode = http.const.StrictConnectionMode.DISABLED + elif not isinstance(mode, http.const.StrictConnectionMode): mode = http.const.StrictConnectionMode(mode) - return mode # type: ignore[no-any-return] + return mode async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" @@ -430,5 +433,5 @@ class CloudPreferences: PREF_REMOTE_DOMAIN: None, PREF_REMOTE_ALLOW_REMOTE_ENABLE: True, PREF_USERNAME: username, - PREF_STRICT_CONNECTION: http.const.StrictConnectionMode.DISABLED, + PREF_STRICT_CONNECTION: None, } diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 1ed2e1d524f..57715fe2bdf 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -197,3 +197,21 @@ async def test_strict_connection_convertion( await hass.async_block_till_done() assert cloud.client.prefs.strict_connection is mode + + +@pytest.mark.parametrize("storage_data", [{}, {PREF_STRICT_CONNECTION: None}]) +async def test_strict_connection_default( + hass: HomeAssistant, + cloud: MagicMock, + hass_storage: dict[str, Any], + storage_data: dict[str, Any], +) -> None: + """Test strict connection default values.""" + hass_storage[STORAGE_KEY] = { + "version": 1, + "data": storage_data, + } + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() + + assert cloud.client.prefs.strict_connection is StrictConnectionMode.DISABLED From e9c4185cf64bd258ce85d668b78f643a0d30918c Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 26 Apr 2024 13:03:16 +0100 Subject: [PATCH 045/272] Fix state classes for ovo energy sensors (#116225) * Fix state classes for ovo energy sensors * Restore monetary values Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --------- Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- homeassistant/components/ovo_energy/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py index 5b16e8cdef5..3012a130a1a 100644 --- a/homeassistant/components/ovo_energy/sensor.py +++ b/homeassistant/components/ovo_energy/sensor.py @@ -54,7 +54,7 @@ SENSOR_TYPES_ELECTRICITY: tuple[OVOEnergySensorEntityDescription, ...] = ( key=KEY_LAST_ELECTRICITY_COST, translation_key=KEY_LAST_ELECTRICITY_COST, device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value=lambda usage: usage.electricity[-1].cost.amount if usage.electricity[-1].cost is not None else None, @@ -88,7 +88,7 @@ SENSOR_TYPES_GAS: tuple[OVOEnergySensorEntityDescription, ...] = ( key=KEY_LAST_GAS_COST, translation_key=KEY_LAST_GAS_COST, device_class=SensorDeviceClass.MONETARY, - state_class=SensorStateClass.TOTAL_INCREASING, + state_class=SensorStateClass.TOTAL, value=lambda usage: usage.gas[-1].cost.amount if usage.gas[-1].cost is not None else None, From 603f46184cc4b9d722b2bcf8d38e092c61174886 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Fri, 26 Apr 2024 15:40:32 +0200 Subject: [PATCH 046/272] Update frontend to 20240426.0 (#116230) --- 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 ad63bdbed84..a5446f688ba 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==20240424.1"] + "requirements": ["home-assistant-frontend==20240426.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 442db45e714..1b4223d7b33 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6ab10019d78..ebef89bd0e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc69a55c955..18b9c0c31e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240424.1 +home-assistant-frontend==20240426.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From 8d11a9f21aa2c8b187ae73d49437663275a3a760 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:25:19 -0500 Subject: [PATCH 047/272] Move thread safety check in entity_registry sooner (#116263) * Move thread safety check in entity_registry sooner It turns out we have a lot of custom components that are writing to the entity registry using the async APIs from threads. We now catch it at the point async_fire is called. Instread we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. * coverage * Apply suggestions from code review --- homeassistant/helpers/entity_registry.py | 10 ++++-- tests/helpers/test_entity_registry.py | 44 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 436fc5a18de..589b379cf08 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -819,6 +819,7 @@ class EntityRegistry(BaseRegistry): unit_of_measurement=unit_of_measurement, ) + self.hass.verify_event_loop_thread("async_get_or_create") _validate_item( self.hass, domain, @@ -879,7 +880,7 @@ class EntityRegistry(BaseRegistry): _LOGGER.info("Registered new %s.%s entity: %s", domain, platform, entity_id) self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_ENTITY_REGISTRY_UPDATED, _EventEntityRegistryUpdatedData_CreateRemove( action="create", entity_id=entity_id @@ -891,6 +892,7 @@ class EntityRegistry(BaseRegistry): @callback def async_remove(self, entity_id: str) -> None: """Remove an entity from registry.""" + self.hass.verify_event_loop_thread("async_remove") entity = self.entities.pop(entity_id) config_entry_id = entity.config_entry_id key = (entity.domain, entity.platform, entity.unique_id) @@ -904,7 +906,7 @@ class EntityRegistry(BaseRegistry): platform=entity.platform, unique_id=entity.unique_id, ) - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_ENTITY_REGISTRY_UPDATED, _EventEntityRegistryUpdatedData_CreateRemove( action="remove", entity_id=entity_id @@ -1085,6 +1087,8 @@ class EntityRegistry(BaseRegistry): if not new_values: return old + self.hass.verify_event_loop_thread("_async_update_entity") + new = self.entities[entity_id] = attr.evolve(old, **new_values) self.async_schedule_save() @@ -1098,7 +1102,7 @@ class EntityRegistry(BaseRegistry): if old.entity_id != entity_id: data["old_entity_id"] = old.entity_id - self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, data) + self.hass.bus.async_fire_internal(EVENT_ENTITY_REGISTRY_UPDATED, data) return new diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 60971d98df2..bc3b2d6f705 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -1,6 +1,7 @@ """Tests for the Entity Registry.""" from datetime import timedelta +from functools import partial from typing import Any from unittest.mock import patch @@ -1988,3 +1989,46 @@ async def test_entries_for_category(entity_registry: er.EntityRegistry) -> None: assert not er.async_entries_for_category(entity_registry, "", "id") assert not er.async_entries_for_category(entity_registry, "scope1", "unknown") assert not er.async_entries_for_category(entity_registry, "scope1", "") + + +async def test_get_or_create_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_get_or_create_from a thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls async_get_or_create from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + entity_registry.async_get_or_create, "light", "hue", "1234" + ) + + +async def test_async_update_entity_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_get_or_create from a thread.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + with pytest.raises( + RuntimeError, + match="Detected code that calls _async_update_entity from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial( + entity_registry.async_update_entity, + entry.entity_id, + new_unique_id="5678", + ) + ) + + +async def test_async_remove_thread_safety( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test call async_remove from a thread.""" + entry = entity_registry.async_get_or_create("light", "hue", "1234") + with pytest.raises( + RuntimeError, + match="Detected code that calls async_remove from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(entity_registry.async_remove, entry.entity_id) From 85baa2508d4f82a110cc9a7d171dd3de779ebbef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:24:55 -0500 Subject: [PATCH 048/272] Move thread safety check in device_registry sooner (#116264) It turns out we have custom components that are writing to the device registry using the async APIs from threads. We now catch it at the point async_fire is called. Instead we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. --- homeassistant/helpers/device_registry.py | 6 ++- tests/helpers/test_device_registry.py | 47 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 00d0a0ba62f..0e64540f11a 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -904,6 +904,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): if not new_values: return old + self.hass.verify_event_loop_thread("async_update_device") new = attr.evolve(old, **new_values) self.devices[device_id] = new @@ -923,13 +924,14 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): else: data = {"action": "update", "device_id": new.id, "changes": old_values} - self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, data) + self.hass.bus.async_fire_internal(EVENT_DEVICE_REGISTRY_UPDATED, data) return new @callback def async_remove_device(self, device_id: str) -> None: """Remove a device from the device registry.""" + self.hass.verify_event_loop_thread("async_remove_device") device = self.devices.pop(device_id) self.deleted_devices[device_id] = DeletedDeviceEntry( config_entries=device.config_entries, @@ -941,7 +943,7 @@ class DeviceRegistry(BaseRegistry[dict[str, list[dict[str, Any]]]]): for other_device in list(self.devices.values()): if other_device.via_device_id == device_id: self.async_update_device(other_device.id, via_device_id=None) - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_DEVICE_REGISTRY_UPDATED, _EventDeviceRegistryUpdatedData_CreateRemove( action="remove", device_id=device_id diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index ee895e3fd3e..6b167f8ee49 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -2,6 +2,7 @@ from collections.abc import Iterable from contextlib import AbstractContextManager, nullcontext +from functools import partial import time from typing import Any from unittest.mock import patch @@ -2473,3 +2474,49 @@ async def test_device_name_translation_placeholders_errors( ) assert expected_error in caplog.text + + +async def test_async_get_or_create_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_get_or_create raises when called from wrong thread.""" + + with pytest.raises( + RuntimeError, + match="Detected code that calls async_update_device from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial( + device_registry.async_get_or_create, + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers=set(), + manufacturer="manufacturer", + model="model", + ) + ) + + +async def test_async_remove_device_thread_safety( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test async_remove_device raises when called from wrong thread.""" + device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers=set(), + manufacturer="manufacturer", + model="model", + ) + + with pytest.raises( + RuntimeError, + match="Detected code that calls async_remove_device from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + device_registry.async_remove_device, device.id + ) From 46dff86d1adbbd253c21ca21f03a0a8f41a91188 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 02:26:35 -0500 Subject: [PATCH 049/272] Move thread safety check in area_registry sooner (#116265) It turns out we have custom components that are writing to the area registry using the async APIs from threads. We now catch it at the point async_fire is called. Instead we should check sooner and use async_fire_internal so we catch the unsafe operation before it can corrupt the registry. --- homeassistant/helpers/area_registry.py | 11 ++++++-- tests/helpers/test_area_registry.py | 38 ++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index b39fee9c185..4dba510396f 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -202,6 +202,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): picture: str | None = None, ) -> AreaEntry: """Create a new area.""" + self.hass.verify_event_loop_thread("async_create") normalized_name = normalize_name(name) if self.async_get_area_by_name(name): @@ -221,7 +222,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): assert area.id is not None self.areas[area.id] = area self.async_schedule_save() - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="create", area_id=area.id), ) @@ -230,6 +231,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): @callback def async_delete(self, area_id: str) -> None: """Delete area.""" + self.hass.verify_event_loop_thread("async_delete") device_registry = dr.async_get(self.hass) entity_registry = er.async_get(self.hass) device_registry.async_clear_area_id(area_id) @@ -237,7 +239,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): del self.areas[area_id] - self.hass.bus.async_fire( + self.hass.bus.async_fire_internal( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="remove", area_id=area_id), ) @@ -266,6 +268,10 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): name=name, picture=picture, ) + # Since updated may be the old or the new and we always fire + # an event even if nothing has changed we cannot use async_fire_internal + # here because we do not know if the thread safety check already + # happened or not in _async_update. self.hass.bus.async_fire( EVENT_AREA_REGISTRY_UPDATED, EventAreaRegistryUpdatedData(action="update", area_id=area_id), @@ -306,6 +312,7 @@ class AreaRegistry(BaseRegistry[AreasRegistryStoreData]): if not new_values: return old + self.hass.verify_event_loop_thread("_async_update") new = self.areas[area_id] = dataclasses.replace(old, **new_values) # type: ignore[arg-type] self.async_schedule_save() diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 1ee8a42b6b9..22f1dc8e534 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,5 +1,6 @@ """Tests for the Area Registry.""" +from functools import partial from typing import Any import pytest @@ -491,3 +492,40 @@ async def test_entries_for_label( assert not ar.async_entries_for_label(area_registry, "unknown") assert not ar.async_entries_for_label(area_registry, "") + + +async def test_async_get_or_create_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to create in the wrong thread.""" + with pytest.raises( + RuntimeError, + match="Detected code that calls async_create from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(area_registry.async_create, "Mock1") + + +async def test_async_update_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to update in the wrong thread.""" + area = area_registry.async_create("Mock1") + with pytest.raises( + RuntimeError, + match="Detected code that calls _async_update from a thread. Please report this issue.", + ): + await hass.async_add_executor_job( + partial(area_registry.async_update, area.id, name="Mock2") + ) + + +async def test_async_delete_thread_checks( + hass: HomeAssistant, area_registry: ar.AreaRegistry +) -> None: + """We raise when trying to delete in the wrong thread.""" + area = area_registry.async_create("Mock1") + with pytest.raises( + RuntimeError, + match="Detected code that calls async_delete from a thread. Please report this issue.", + ): + await hass.async_add_executor_job(area_registry.async_delete, area.id) From 3c48c4173494894fd8e7561865e94346e9fe232b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 27 Apr 2024 03:24:23 -0400 Subject: [PATCH 050/272] Bump zwave-js-server-python to 0.55.4 (#116278) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a06de5cb8ee..83a139331bb 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["zwave_js_server"], "quality_scale": "platinum", - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.3"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.55.4"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index ebef89bd0e0..d78a00ca68e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2965,7 +2965,7 @@ zigpy==0.64.0 zm-py==0.5.4 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.3 +zwave-js-server-python==0.55.4 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18b9c0c31e3..ed5ec38af1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2303,7 +2303,7 @@ zigpy-znp==0.12.1 zigpy==0.64.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.55.3 +zwave-js-server-python==0.55.4 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 9819cdfec22fc99d78e7570676b977f66f023011 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 27 Apr 2024 07:27:57 +0000 Subject: [PATCH 051/272] Bump version to 2024.5.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 1abfe08b93c..a56405d810a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 0427019a29e..fc2f658a9c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b0" +version = "2024.5.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a37d274b373e418cccb36ddec40613af1ee45f8e Mon Sep 17 00:00:00 2001 From: hopkins-tk Date: Sat, 27 Apr 2024 10:02:52 +0200 Subject: [PATCH 052/272] Fix Aseko binary sensors names (#116251) * Fix Aseko binary sensors names * Fix add missing key to strings.json * Fix remove setting shorthand translation key attribute * Update homeassistant/components/aseko_pool_live/strings.json --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/aseko_pool_live/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index dbbdff38200..79953565769 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -42,6 +42,7 @@ UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( ), AsekoBinarySensorEntityDescription( key="has_error", + translation_key="error", value_fn=lambda unit: unit.has_error, device_class=BinarySensorDeviceClass.PROBLEM, ), @@ -78,7 +79,6 @@ class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity): """Initialize the unit binary sensor.""" super().__init__(unit, coordinator) self.entity_description = entity_description - self._attr_name = f"{self._device_name} {entity_description.name}" self._attr_unique_id = f"{self._unit.serial_number}_{entity_description.key}" @property From 7715bee6b0949c492b6f4aec1b29243126b1987f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 07:08:29 -0500 Subject: [PATCH 053/272] Fix script in restart mode that is fired from the same trigger (#116247) --- homeassistant/helpers/script.py | 20 +++--- tests/components/automation/test_init.py | 82 +++++++++++++++++++++++- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d925bf215ab..d739fbfef98 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1692,7 +1692,7 @@ class Script: script_stack = script_stack_cv.get() if ( self.script_mode in (SCRIPT_MODE_RESTART, SCRIPT_MODE_QUEUED) - and (script_stack := script_stack_cv.get()) is not None + and script_stack is not None and id(self) in script_stack ): script_execution_set("disallowed_recursion_detected") @@ -1706,15 +1706,19 @@ class Script: run = cls( self._hass, self, cast(dict, variables), context, self._log_exceptions ) + has_existing_runs = bool(self._runs) self._runs.append(run) - if self.script_mode == SCRIPT_MODE_RESTART: + if self.script_mode == SCRIPT_MODE_RESTART and has_existing_runs: # When script mode is SCRIPT_MODE_RESTART, first add the new run and then # stop any other runs. If we stop other runs first, self.is_running will # return false after the other script runs were stopped until our task - # resumes running. + # resumes running. Its important that we check if there are existing + # runs before sleeping as otherwise if two runs are started at the exact + # same time they will cancel each other out. self._log("Restarting") # Important: yield to the event loop to allow the script to start in case - # the script is restarting itself. + # the script is restarting itself so it ends up in the script stack and + # the recursion check above will prevent the script from running. await asyncio.sleep(0) await self.async_stop(update_state=False, spare=run) @@ -1730,9 +1734,7 @@ class Script: self._changed() raise - async def _async_stop( - self, aws: list[asyncio.Task], update_state: bool, spare: _ScriptRun | None - ) -> None: + async def _async_stop(self, aws: list[asyncio.Task], update_state: bool) -> None: await asyncio.wait(aws) if update_state: self._changed() @@ -1749,9 +1751,7 @@ class Script: ] if not aws: return - await asyncio.shield( - create_eager_task(self._async_stop(aws, update_state, spare)) - ) + await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) async def _async_get_condition(self, config): if isinstance(config, template.Template): diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 61e6d0e4660..edf0eff878b 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components import automation +from homeassistant.components import automation, input_boolean, script from homeassistant.components.automation import ( ATTR_SOURCE, DOMAIN, @@ -41,6 +41,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, SCRIPT_MODE_PARALLEL, @@ -2980,3 +2981,82 @@ async def test_automation_turns_off_other_automation( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_two_automations_call_restart_script_same_time( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test two automations that call a restart mode script at the same.""" + hass.states.async_set("binary_sensor.presence", "off") + await hass.async_block_till_done() + events = [] + + @callback + def _save_event(event): + events.append(event) + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + { + input_boolean.DOMAIN: { + "test_1": None, + } + }, + ) + cancel = async_track_state_change_event(hass, "input_boolean.test_1", _save_event) + + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "fire_toggle": { + "sequence": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test_1"}, + } + ] + }, + } + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "to": "on", + }, + "action": { + "service": "script.fire_toggle", + }, + "id": "automation_0", + "mode": "single", + }, + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "to": "on", + }, + "action": { + "service": "script.fire_toggle", + }, + "id": "automation_1", + "mode": "single", + }, + ] + }, + ) + + hass.states.async_set("binary_sensor.presence", "on") + await hass.async_block_till_done() + assert len(events) == 2 + cancel() From b94b93cc633fd90b7abb1f4ccee554b6bb920836 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 27 Apr 2024 14:08:56 +0200 Subject: [PATCH 054/272] Make freezegun find event.time_tracker_utcnow (#116284) --- tests/patch_time.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/patch_time.py b/tests/patch_time.py index 3489d4a6baf..c8052b3b8ac 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -6,6 +6,7 @@ import datetime import time from homeassistant import runner, util +from homeassistant.helpers import event as event_helper from homeassistant.util import dt as dt_util @@ -19,6 +20,9 @@ def _monotonic() -> float: return time.monotonic() +# Replace partial functions which are not found by freezegun dt_util.utcnow = _utcnow # type: ignore[assignment] +event_helper.time_tracker_utcnow = _utcnow # type: ignore[assignment] util.utcnow = _utcnow # type: ignore[assignment] + runner.monotonic = _monotonic # type: ignore[assignment] From eea66921bb45a37ae624a53368e83eefa7fbed40 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 10:48:05 -0500 Subject: [PATCH 055/272] Avoid update call in entity state write if there is no customize data (#116296) --- homeassistant/helpers/entity.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a2fc16f8a82..b185e3316c5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1189,8 +1189,10 @@ class Entity( ) # Overwrite properties that have been set in the config file. - if customize := hass.data.get(DATA_CUSTOMIZE): - attr.update(customize.get(entity_id)) + if (customize := hass.data.get(DATA_CUSTOMIZE)) and ( + custom := customize.get(entity_id) + ): + attr.update(custom) if ( self._context_set is not None From f295172d078c75b1563a8b9d9f8af29067bc4c76 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 10:48:17 -0500 Subject: [PATCH 056/272] Add a fast path for _stringify_state when state is already a str (#116295) --- homeassistant/helpers/entity.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index b185e3316c5..04e674596a2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1014,6 +1014,9 @@ class Entity( return STATE_UNAVAILABLE if (state := self.state) is None: return STATE_UNKNOWN + if type(state) is str: # noqa: E721 + # fast path for strings + return state if isinstance(state, float): # If the entity's state is a float, limit precision according to machine # epsilon to make the string representation readable From cbcfd71f3da166f0d97c80391b58459a0313c83f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 13:17:31 -0500 Subject: [PATCH 057/272] Reduce number of time calls needed to write state (#116297) --- homeassistant/core.py | 4 +++- homeassistant/helpers/entity.py | 18 ++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 604840e542d..a2808568f29 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2205,6 +2205,7 @@ class StateMachine: force_update: bool = False, context: Context | None = None, state_info: StateInfo | None = None, + timestamp: float | None = None, ) -> None: """Set the state of an entity, add entity if it does not exist. @@ -2244,7 +2245,8 @@ class StateMachine: # timestamp implementation: # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387 # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323 - timestamp = time.time() + if timestamp is None: + timestamp = time.time() now = dt_util.utc_from_timestamp(timestamp) if same_state and same_attr: diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 04e674596a2..07d5410f3f2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -14,7 +14,7 @@ import logging import math from operator import attrgetter import sys -from timeit import default_timer as timer +import time from types import FunctionType from typing import ( TYPE_CHECKING, @@ -74,6 +74,8 @@ from .event import ( ) from .typing import UNDEFINED, StateType, UndefinedType +timer = time.time + if TYPE_CHECKING: from .entity_platform import EntityPlatform @@ -927,7 +929,7 @@ class Entity( def async_set_context(self, context: Context) -> None: """Set the context the entity currently operates under.""" self._context = context - self._context_set = self.hass.loop.time() + self._context_set = time.time() async def async_update_ha_state(self, force_refresh: bool = False) -> None: """Update Home Assistant with current state of entity. @@ -1131,9 +1133,9 @@ class Entity( ) return - start = timer() + state_calculate_start = timer() state, attr, capabilities, shadowed_attr = self.__async_calculate_state() - end = timer() + time_now = timer() if entry: # Make sure capabilities in the entity registry are up to date. Capabilities @@ -1146,7 +1148,6 @@ class Entity( or supported_features != entry.supported_features ): if not self.__capabilities_updated_at_reported: - time_now = hass.loop.time() # _Entity__capabilities_updated_at is because of name mangling if not ( capabilities_updated_at := getattr( @@ -1180,14 +1181,14 @@ class Entity( supported_features=supported_features, ) - if end - start > 0.4 and not self._slow_reported: + if time_now - state_calculate_start > 0.4 and not self._slow_reported: self._slow_reported = True report_issue = self._suggest_report_issue() _LOGGER.warning( "Updating state for %s (%s) took %.3f seconds. Please %s", entity_id, type(self), - end - start, + time_now - state_calculate_start, report_issue, ) @@ -1199,7 +1200,7 @@ class Entity( if ( self._context_set is not None - and hass.loop.time() - self._context_set > CONTEXT_RECENT_TIME_SECONDS + and time_now - self._context_set > CONTEXT_RECENT_TIME_SECONDS ): self._context = None self._context_set = None @@ -1212,6 +1213,7 @@ class Entity( self.force_update, self._context, self._state_info, + time_now, ) except InvalidStateError: _LOGGER.exception( From 83b5ecb36f8629601b9ae06fe95ec2ecf0ad34a5 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Sat, 27 Apr 2024 14:46:58 -0400 Subject: [PATCH 058/272] Increase the Hydrawise refresh frequency from 120s to 30s (#116298) --- homeassistant/components/hydrawise/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py index 724b6ee6203..08862246613 100644 --- a/homeassistant/components/hydrawise/const.py +++ b/homeassistant/components/hydrawise/const.py @@ -13,6 +13,6 @@ DEFAULT_WATERING_TIME = timedelta(minutes=15) MANUFACTURER = "Hydrawise" -SCAN_INTERVAL = timedelta(seconds=120) +SCAN_INTERVAL = timedelta(seconds=30) SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" From cf682c0c447ca13a90b24b084d0288dff470809b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 14:24:02 -0500 Subject: [PATCH 059/272] Use more shorthand attrs in emonitor (#116307) --- homeassistant/components/emonitor/sensor.py | 33 ++++++++------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/emonitor/sensor.py b/homeassistant/components/emonitor/sensor.py index 551e47a91a4..05071800f28 100644 --- a/homeassistant/components/emonitor/sensor.py +++ b/homeassistant/components/emonitor/sensor.py @@ -12,11 +12,10 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfPower -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -63,7 +62,7 @@ async def async_setup_entry( async_add_entities(entities) -class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): +class EmonitorPowerSensor(CoordinatorEntity[EmonitorStatus], SensorEntity): """Representation of an Emonitor power sensor entity.""" _attr_device_class = SensorDeviceClass.POWER @@ -81,7 +80,8 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): self.entity_description = description self.channel_number = channel_number super().__init__(coordinator) - mac_address = self.emonitor_status.network.mac_address + emonitor_status = self.coordinator.data + mac_address = emonitor_status.network.mac_address device_name = name_short_mac(mac_address[-6:]) label = self.channel_data.label or str(channel_number) if description.translation_key is not None: @@ -94,13 +94,15 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): connections={(dr.CONNECTION_NETWORK_MAC, mac_address)}, manufacturer="Powerhouse Dynamics, Inc.", name=device_name, - sw_version=self.emonitor_status.hardware.firmware_version, + sw_version=emonitor_status.hardware.firmware_version, ) + self._attr_extra_state_attributes = {"channel": channel_number} + self._attr_native_value = self._paired_attr(self.entity_description.key) @property def channels(self) -> dict[int, EmonitorChannel]: """Return the channels dict.""" - channels: dict[int, EmonitorChannel] = self.emonitor_status.channels + channels: dict[int, EmonitorChannel] = self.coordinator.data.channels return channels @property @@ -108,11 +110,6 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): """Return the channel data.""" return self.channels[self.channel_number] - @property - def emonitor_status(self) -> EmonitorStatus: - """Return the EmonitorStatus.""" - return self.coordinator.data - def _paired_attr(self, attr_name: str) -> float: """Cumulative attributes for channel and paired channel.""" channel_data = self.channels[self.channel_number] @@ -121,12 +118,8 @@ class EmonitorPowerSensor(CoordinatorEntity, SensorEntity): attr_val += getattr(self.channels[paired_channel], attr_name) return attr_val - @property - def native_value(self) -> StateType: - """State of the sensor.""" - return self._paired_attr(self.entity_description.key) - - @property - def extra_state_attributes(self) -> dict[str, int]: - """Return the device specific state attributes.""" - return {"channel": self.channel_number} + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self._paired_attr(self.entity_description.key) + return super()._handle_coordinator_update() From 3fd863bd7c51e422580296ae968d287d7bfd4f04 Mon Sep 17 00:00:00 2001 From: wittypluck Date: Sat, 27 Apr 2024 22:21:08 +0200 Subject: [PATCH 060/272] Unifi: enable statistics for PoE port power sensor (#116308) Add SensorStateClass.MEASUREMENT to PoE port power sensor --- homeassistant/components/unifi/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 17b3cae93fd..2685f075cd5 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -228,6 +228,7 @@ ENTITY_DESCRIPTIONS: tuple[UnifiSensorEntityDescription, ...] = ( key="PoE port power sensor", device_class=SensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfPower.WATT, entity_registry_enabled_default=False, api_handler_fn=lambda api: api.ports, From 9fb01f3956c19831367be7644b637dabe523e6e3 Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Sat, 27 Apr 2024 17:11:52 -0400 Subject: [PATCH 061/272] Convert Linear to use a base entity (#116133) * Convert Linear to use a base entity * Convert Linear to use a base entity * Remove str cast in coordinator * More minor fixes --- .../components/linear_garage_door/cover.py | 38 ++-------------- .../components/linear_garage_door/entity.py | 43 +++++++++++++++++++ 2 files changed, 47 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/linear_garage_door/entity.py diff --git a/homeassistant/components/linear_garage_door/cover.py b/homeassistant/components/linear_garage_door/cover.py index b3d720e531a..1f7ae7ce114 100644 --- a/homeassistant/components/linear_garage_door/cover.py +++ b/homeassistant/components/linear_garage_door/cover.py @@ -10,12 +10,11 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN -from .coordinator import LinearDevice, LinearUpdateCoordinator +from .coordinator import LinearUpdateCoordinator +from .entity import LinearEntity SUPPORTED_SUBDEVICES = ["GDO"] PARALLEL_UPDATES = 1 @@ -31,49 +30,20 @@ async def async_setup_entry( coordinator: LinearUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - LinearCoverEntity(coordinator, device_id, sub_device_id) + LinearCoverEntity(coordinator, device_id, device_data.name, sub_device_id) for device_id, device_data in coordinator.data.items() for sub_device_id in device_data.subdevices if sub_device_id in SUPPORTED_SUBDEVICES ) -class LinearCoverEntity(CoordinatorEntity[LinearUpdateCoordinator], CoverEntity): +class LinearCoverEntity(LinearEntity, CoverEntity): """Representation of a Linear cover.""" _attr_supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - _attr_has_entity_name = True _attr_name = None _attr_device_class = CoverDeviceClass.GARAGE - def __init__( - self, - coordinator: LinearUpdateCoordinator, - device_id: str, - sub_device_id: str, - ) -> None: - """Init with device ID and name.""" - super().__init__(coordinator) - self._device_id = device_id - self._sub_device_id = sub_device_id - self._attr_unique_id = f"{device_id}-{sub_device_id}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, sub_device_id)}, - name=self.linear_device.name, - manufacturer="Linear", - model="Garage Door Opener", - ) - - @property - def linear_device(self) -> LinearDevice: - """Return the Linear device.""" - return self.coordinator.data[self._device_id] - - @property - def sub_device(self) -> dict[str, str]: - """Return the subdevice.""" - return self.linear_device.subdevices[self._sub_device_id] - @property def is_closed(self) -> bool: """Return if cover is closed.""" diff --git a/homeassistant/components/linear_garage_door/entity.py b/homeassistant/components/linear_garage_door/entity.py new file mode 100644 index 00000000000..a7adf95f82e --- /dev/null +++ b/homeassistant/components/linear_garage_door/entity.py @@ -0,0 +1,43 @@ +"""Base entity for Linear.""" + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import LinearDevice, LinearUpdateCoordinator + + +class LinearEntity(CoordinatorEntity[LinearUpdateCoordinator]): + """Common base for Linear entities.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: LinearUpdateCoordinator, + device_id: str, + device_name: str, + sub_device_id: str, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{device_id}-{sub_device_id}" + self._device_id = device_id + self._sub_device_id = sub_device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_id)}, + name=device_name, + manufacturer="Linear", + model="Garage Door Opener", + ) + + @property + def linear_device(self) -> LinearDevice: + """Return the Linear device.""" + return self.coordinator.data[self._device_id] + + @property + def sub_device(self) -> dict[str, str]: + """Return the subdevice.""" + return self.linear_device.subdevices[self._sub_device_id] From 50405fae5f4f3e55a664d3efcdf3283e0ddd48a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 18:42:29 -0500 Subject: [PATCH 062/272] Add a cache to _verify_event_type_length_or_raise (#116312) Most of the time the events being fired are the same so we can skip the python code in this function --- homeassistant/core.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/core.py b/homeassistant/core.py index a2808568f29..37baffa6f19 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1408,6 +1408,7 @@ class _OneTimeListener(Generic[_DataT]): EMPTY_LIST: list[Any] = [] +@functools.lru_cache def _verify_event_type_length_or_raise(event_type: EventType[_DataT] | str) -> None: """Verify the length of the event type and raise if too long.""" if len(event_type) > MAX_LENGTH_EVENT_EVENT_TYPE: From 43dc5415de73c148d824b61c937f0dfa827df114 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 28 Apr 2024 01:42:38 +0200 Subject: [PATCH 063/272] Fix no will published when mqtt is down (#116319) --- homeassistant/components/mqtt/client.py | 3 ++- tests/components/mqtt/test_init.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index f01b8e80b3d..d094776efe0 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -710,7 +710,8 @@ class MQTT: async with self._connection_lock: self._should_reconnect = False self._async_cancel_reconnect() - self._mqttc.disconnect() + # We do not gracefully disconnect to ensure + # the broker publishes the will message @callback def async_restore_tracked_subscriptions( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 9d135b89f36..cfb8ce7ac04 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -141,17 +141,17 @@ async def test_mqtt_connects_on_home_assistant_mqtt_setup( assert mqtt_client_mock.connect.call_count == 1 -async def test_mqtt_disconnects_on_home_assistant_stop( +async def test_mqtt_does_not_disconnect_on_home_assistant_stop( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: - """Test if client stops on HA stop.""" + """Test if client is not disconnected on HA stop.""" await mqtt_mock_entry() hass.bus.fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() await hass.async_block_till_done() - assert mqtt_client_mock.disconnect.call_count == 1 + assert mqtt_client_mock.disconnect.call_count == 0 async def test_mqtt_await_ack_at_disconnect( From 5d59b4cddd9fa4d402878a5d627bfd9cd90a6826 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 19:34:17 -0500 Subject: [PATCH 064/272] Remove unneeded TYPE_CHECKING guard in core async_set (#116311) --- homeassistant/core.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 37baffa6f19..7a5d2b22862 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -2267,8 +2267,6 @@ class StateMachine: return if context is None: - if TYPE_CHECKING: - assert timestamp is not None context = Context(id=ulid_at_time(timestamp)) if same_attr: From 76639252c9a0a6afbe8d8839e90cd2afd5860ff9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:13:37 -0500 Subject: [PATCH 065/272] Make discovery flow tasks background tasks (#116327) --- homeassistant/config_entries.py | 1 + homeassistant/helpers/discovery_flow.py | 2 +- tests/components/gardena_bluetooth/test_config_flow.py | 2 +- tests/components/hassio/test_init.py | 2 +- tests/components/homeassistant_yellow/test_init.py | 8 ++++---- tests/components/plex/test_config_flow.py | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 056814bbc4d..88230a78428 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1157,6 +1157,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): cooldown=DISCOVERY_COOLDOWN, immediate=True, function=self._async_discovery, + background=True, ) async def async_wait_import_flow_initialized(self, handler: str) -> None: diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 314777733c3..e479a47ecfd 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -30,7 +30,7 @@ def async_create_flow( if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): - hass.async_create_task( + hass.async_create_background_task( init_coro, f"discovery flow {domain} {context}", eager_start=True ) return diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index 7707a13180f..3b4e9c242b3 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -103,7 +103,7 @@ async def test_bluetooth( # Inject the service info will trigger the flow to start inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index da49b8d9f16..572593d642b 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1098,7 +1098,7 @@ async def test_setup_hardware_integration( ) as mock_setup_entry, ): result = await async_setup_component(hass, "hassio", {"hassio": {}}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert result assert aioclient_mock.call_count == 19 diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 0631c2cb983..ec3ba4e7005 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -44,7 +44,7 @@ async def test_setup_entry( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 @@ -90,7 +90,7 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA @@ -144,7 +144,7 @@ async def test_setup_zha_multipan( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA @@ -198,7 +198,7 @@ async def test_setup_zha_multipan_other_device( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 33e1b3637d8..5f2531992d4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -722,7 +722,7 @@ async def test_integration_discovery(hass: HomeAssistant) -> None: with patch("homeassistant.components.plex.config_flow.GDM", return_value=mock_gdm): await config_flow.async_discover(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) flows = hass.config_entries.flow.async_progress() From 3f0c0a72dbbcb2cdf0efa0bb9f7c48583a35bbdd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:13:51 -0500 Subject: [PATCH 066/272] Prevent setup retry from delaying shutdown (#116328) --- homeassistant/config_entries.py | 2 +- .../components/gardena_bluetooth/test_init.py | 2 +- .../specific_devices/test_ecobee3.py | 1 + .../homekit_controller/test_init.py | 6 +++-- tests/components/teslemetry/test_init.py | 2 +- tests/components/wiz/test_init.py | 4 ++-- tests/components/yeelight/test_init.py | 22 +++++++++---------- tests/components/zha/test_init.py | 3 ++- 8 files changed, 23 insertions(+), 19 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 88230a78428..73e1d8debd6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -698,7 +698,7 @@ class ConfigEntry: # Check again when we fire in case shutdown # has started so we do not block shutdown if not hass.is_stopping: - hass.async_create_task( + hass.async_create_background_task( self._async_setup_retry(hass), f"config entry retry {self.domain} {self.title}", eager_start=True, diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index 1f294c6169d..53688846c07 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -57,6 +57,6 @@ async def test_setup_retry( mock_client.read_char.side_effect = original_read_char async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert mock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 3f93ca1a896..059993e3bef 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -203,6 +203,7 @@ async def test_ecobee3_setup_connection_failure( # We just advance time by 5 minutes so that the retry happens, rather # than manually invoking async_setup_entry. await time_changed(hass, 5 * 60) + await hass.async_block_till_done(wait_background_tasks=True) climate = entity_registry.async_get("climate.homew") assert climate.unique_id == "00:00:00:00:00:00_1_16" diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 59fdf555a50..9d2022f6b1c 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -160,7 +160,7 @@ async def test_offline_device_raises(hass: HomeAssistant, controller) -> None: is_connected = True async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.testdevice").state == STATE_OFF @@ -217,16 +217,18 @@ async def test_ble_device_only_checks_is_available( is_available = True async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.testdevice").state == STATE_OFF is_available = False async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("light.testdevice").state == STATE_UNAVAILABLE is_available = True async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("light.testdevice").state == STATE_OFF diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index fb405e2ee03..f21a421ed6e 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -74,7 +74,7 @@ async def test_vehicle_first_refresh( # Wait for the retry freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify we have loaded assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/wiz/test_init.py b/tests/components/wiz/test_init.py index 3fa369c4d9d..c3438aed1b2 100644 --- a/tests/components/wiz/test_init.py +++ b/tests/components/wiz/test_init.py @@ -32,9 +32,9 @@ async def test_setup_retry(hass: HomeAssistant) -> None: bulb.getMac = AsyncMock(return_value=FAKE_MAC) with _patch_discovery(), _patch_wizlight(device=bulb): - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=15)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index af442d1c8d0..0bff635fb6e 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -69,7 +69,7 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # The discovery should update the ip address assert config_entry.data[CONF_HOST] == IP_ADDRESS @@ -78,7 +78,7 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( @@ -362,7 +362,7 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant) -> None: _patch_discovery_interval(), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -380,7 +380,7 @@ async def test_async_listen_error_late_discovery( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY await hass.async_block_till_done() @@ -388,7 +388,7 @@ async def test_async_listen_error_late_discovery( with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data[CONF_DETECTED_MODEL] == MODEL @@ -411,7 +411,7 @@ async def test_fail_to_fetch_initial_state( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY await hass.async_block_till_done() @@ -419,7 +419,7 @@ async def test_fail_to_fetch_initial_state( with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -502,7 +502,7 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.data[CONF_ID] == ID async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with ( _patch_discovery(), @@ -511,7 +511,7 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant) -> None: patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -535,7 +535,7 @@ async def test_async_setup_with_missing_unique_id(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.unique_id == ID async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with ( _patch_discovery(), @@ -544,7 +544,7 @@ async def test_async_setup_with_missing_unique_id(hass: HomeAssistant) -> None: patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 99d6a78924b..70ba88ee6e7 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -255,10 +255,11 @@ async def test_zha_retry_unique_ids( lambda hass, delay, action: async_call_later(hass, 0, action), ): await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Wait for the config entry setup to retry await asyncio.sleep(0.1) + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_connect.mock_calls) == 2 From ce42ad187c169a81d45b77edf7515b59e657862a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:04 -0500 Subject: [PATCH 067/272] Fix unifiprotect delaying shutdown if websocket if offline (#116331) --- homeassistant/components/unifiprotect/data.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index c0a6d65ff7a..55ddf91d3cb 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -269,7 +269,12 @@ class ProtectData: this will be a no-op. If the websocket is disconnected, this will trigger a reconnect and refresh. """ - self._hass.async_create_task(self.async_refresh(), eager_start=True) + self._entry.async_create_background_task( + self._hass, + self.async_refresh(), + name=f"{DOMAIN} {self._entry.title} refresh", + eager_start=True, + ) @callback def async_subscribe_device_id( From 66e86170b1c263e5c97151ab6081aab91613320e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:15 -0500 Subject: [PATCH 068/272] Make storage load tasks background tasks to avoid delaying shutdown (#116332) --- homeassistant/helpers/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 20054274275..bf9d49b4f21 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -278,7 +278,7 @@ class Store(Generic[_T]): if self._load_task: return await self._load_task - load_task = self.hass.async_create_task( + load_task = self.hass.async_create_background_task( self._async_load(), f"Storage load {self.key}", eager_start=True ) if not load_task.done(): From 006040270ce0ca6a78743ca01f71fcb30acb31e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:30 -0500 Subject: [PATCH 069/272] Fix august delaying shutdown (#116329) --- homeassistant/components/august/subscriber.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 7294f8bc90f..bec8e2f0b97 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -47,7 +47,9 @@ class AugustSubscriberMixin: @callback def _async_scheduled_refresh(self, now: datetime) -> None: """Call the refresh method.""" - self._hass.async_create_task(self._async_refresh(now), eager_start=True) + self._hass.async_create_background_task( + self._async_refresh(now), name=f"{self} schedule refresh", eager_start=True + ) @callback def _async_cancel_update_interval(self, _: Event | None = None) -> None: From c3aa238a333d8ae3ef2bf0f078ad3b35ec2d56ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:45 -0500 Subject: [PATCH 070/272] Fix wemo push updates delaying shutdown (#116333) --- homeassistant/components/wemo/wemo_device.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 148646736bc..7326e0b42f5 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, fields from datetime import timedelta +from functools import partial import logging from typing import TYPE_CHECKING, Literal @@ -130,7 +131,14 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-en ) else: updated = self.wemo.subscription_update(event_type, params) - self.hass.create_task(self._async_subscription_callback(updated)) + self.hass.loop.call_soon_threadsafe( + partial( + self.hass.async_create_background_task, + self._async_subscription_callback(updated), + f"{self.name} subscription_callback", + eager_start=True, + ) + ) async def async_shutdown(self) -> None: """Unregister push subscriptions and remove from coordinators dict.""" From bf91ab6e2b17d8604445af6308dfb5cb5c5f5618 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:54:34 -0500 Subject: [PATCH 071/272] Fix sonos events delaying shutdown (#116337) --- homeassistant/components/sonos/speaker.py | 22 ++++++++++++++-------- tests/components/sonos/test_switch.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 667e2bb405f..e2529ddfe94 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -407,8 +407,8 @@ class SonosSpeaker: @callback def async_renew_failed(self, exception: Exception) -> None: """Handle a failed subscription renewal.""" - self.hass.async_create_task( - self._async_renew_failed(exception), eager_start=True + self.hass.async_create_background_task( + self._async_renew_failed(exception), "sonos renew failed", eager_start=True ) async def _async_renew_failed(self, exception: Exception) -> None: @@ -451,16 +451,20 @@ class SonosSpeaker: """Add the soco instance associated with the event to the callback.""" if "alarm_list_version" not in event.variables: return - self.hass.async_create_task( - self.alarms.async_process_event(event, self), eager_start=True + self.hass.async_create_background_task( + self.alarms.async_process_event(event, self), + "sonos process event", + eager_start=True, ) @callback def async_dispatch_device_properties(self, event: SonosEvent) -> None: """Update device properties from an event.""" self.event_stats.process(event) - self.hass.async_create_task( - self.async_update_device_properties(event), eager_start=True + self.hass.async_create_background_task( + self.async_update_device_properties(event), + "sonos device properties", + eager_start=True, ) async def async_update_device_properties(self, event: SonosEvent) -> None: @@ -483,8 +487,10 @@ class SonosSpeaker: return if "container_update_i_ds" not in event.variables: return - self.hass.async_create_task( - self.favorites.async_process_event(event, self), eager_start=True + self.hass.async_create_background_task( + self.favorites.async_process_event(event, self), + "sonos dispatch favorites", + eager_start=True, ) @callback diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index eb31d991a3a..d6814886d55 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -157,7 +157,7 @@ async def test_alarm_create_delete( alarm_event.variables["alarm_list_version"] = two_alarms["CurrentAlarmListVersion"] sub_callback(event=alarm_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" in entity_registry.entities @@ -169,7 +169,7 @@ async def test_alarm_create_delete( alarm_clock.ListAlarms.return_value = one_alarm sub_callback(event=alarm_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities From 6ccb165d8eb60f1d447cc28a9951f2ecdd90ef75 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:57:31 -0500 Subject: [PATCH 072/272] Fix bluetooth adapter discovery delaying startup and shutdown (#116335) --- homeassistant/components/bluetooth/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 4768d58379a..acc38cad58b 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -152,6 +152,7 @@ async def _async_start_adapter_discovery( cooldown=BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, immediate=False, function=_async_rediscover_adapters, + background=True, ) @hass_callback From ad0aabe9a1f50c47017d8d0f01e818df238d3d2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 09:21:32 -0500 Subject: [PATCH 073/272] Fix some flapping sonos tests (#116343) --- tests/components/sonos/test_repairs.py | 1 + tests/components/sonos/test_switch.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index 49b87b272d6..2fa951c6a79 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -47,3 +47,4 @@ async def test_subscription_repair_issues( sub_callback(event) await hass.async_block_till_done() assert not issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index d6814886d55..11ce1aa5ddb 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -122,6 +122,7 @@ async def test_switch_attributes( # Trigger subscription callback for speaker discovery await fire_zgs_event() + await hass.async_block_till_done(wait_background_tasks=True) status_light_state = hass.states.get(status_light.entity_id) assert status_light_state.state == STATE_ON From 986df70fe3647f269f649868a0d9414178aca267 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 28 Apr 2024 16:32:17 +0200 Subject: [PATCH 074/272] Refactor group setup (#116317) * Refactor group setup * Add @callback decorator and remove commented out code * Keep set, add default on state --- .../components/air_quality/__init__.py | 3 +- homeassistant/components/air_quality/const.py | 5 ++++ homeassistant/components/air_quality/group.py | 4 ++- .../components/alarm_control_panel/group.py | 6 ++++ homeassistant/components/climate/group.py | 15 ++++++++-- homeassistant/components/cover/__init__.py | 2 +- homeassistant/components/cover/const.py | 3 ++ homeassistant/components/cover/group.py | 4 ++- .../components/device_tracker/group.py | 4 ++- homeassistant/components/group/registry.py | 28 +++++++++++-------- homeassistant/components/lock/__init__.py | 2 +- homeassistant/components/lock/const.py | 3 ++ homeassistant/components/lock/group.py | 4 ++- .../components/media_player/group.py | 12 +++++++- homeassistant/components/person/__init__.py | 3 +- homeassistant/components/person/const.py | 3 ++ homeassistant/components/person/group.py | 4 ++- homeassistant/components/plant/group.py | 4 ++- homeassistant/components/sensor/group.py | 4 ++- homeassistant/components/vacuum/__init__.py | 3 +- homeassistant/components/vacuum/const.py | 2 ++ homeassistant/components/vacuum/group.py | 13 +++++++-- .../components/water_heater/__init__.py | 3 +- .../components/water_heater/const.py | 2 ++ .../components/water_heater/group.py | 7 ++++- homeassistant/components/weather/group.py | 4 ++- 26 files changed, 111 insertions(+), 36 deletions(-) create mode 100644 homeassistant/components/air_quality/const.py create mode 100644 homeassistant/components/cover/const.py create mode 100644 homeassistant/components/lock/const.py create mode 100644 homeassistant/components/person/const.py diff --git a/homeassistant/components/air_quality/__init__.py b/homeassistant/components/air_quality/__init__.py index f23f87019b9..e33fbd34367 100644 --- a/homeassistant/components/air_quality/__init__.py +++ b/homeassistant/components/air_quality/__init__.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType from . import group as group_pre_import # noqa: F401 +from .const import DOMAIN _LOGGER: Final = logging.getLogger(__name__) @@ -33,8 +34,6 @@ ATTR_PM_10: Final = "particulate_matter_10" ATTR_PM_2_5: Final = "particulate_matter_2_5" ATTR_SO2: Final = "sulphur_dioxide" -DOMAIN: Final = "air_quality" - ENTITY_ID_FORMAT: Final = DOMAIN + ".{}" SCAN_INTERVAL: Final = timedelta(seconds=30) diff --git a/homeassistant/components/air_quality/const.py b/homeassistant/components/air_quality/const.py new file mode 100644 index 00000000000..856b8ae3ed4 --- /dev/null +++ b/homeassistant/components/air_quality/const.py @@ -0,0 +1,5 @@ +"""Constants for the air_quality entity platform.""" + +from typing import Final + +DOMAIN: Final = "air_quality" diff --git a/homeassistant/components/air_quality/group.py b/homeassistant/components/air_quality/group.py index 13a70cc4b6b..2bc4a122fdc 100644 --- a/homeassistant/components/air_quality/group.py +++ b/homeassistant/components/air_quality/group.py @@ -7,10 +7,12 @@ from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry +from .const import DOMAIN + @callback def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.exclude_domain() + registry.exclude_domain(DOMAIN) diff --git a/homeassistant/components/alarm_control_panel/group.py b/homeassistant/components/alarm_control_panel/group.py index e0806822cef..5b90b255ada 100644 --- a/homeassistant/components/alarm_control_panel/group.py +++ b/homeassistant/components/alarm_control_panel/group.py @@ -10,9 +10,12 @@ from homeassistant.const import ( STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, STATE_OFF, + STATE_ON, ) from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -23,7 +26,9 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" registry.on_off_states( + DOMAIN, { + STATE_ON, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, @@ -31,5 +36,6 @@ def async_describe_on_off_states( STATE_ALARM_ARMED_VACATION, STATE_ALARM_TRIGGERED, }, + STATE_ON, STATE_OFF, ) diff --git a/homeassistant/components/climate/group.py b/homeassistant/components/climate/group.py index f0b7a748740..9ac4519ff0c 100644 --- a/homeassistant/components/climate/group.py +++ b/homeassistant/components/climate/group.py @@ -2,10 +2,10 @@ from typing import TYPE_CHECKING -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from .const import HVAC_MODES, HVACMode +from .const import DOMAIN, HVACMode if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -17,6 +17,15 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" registry.on_off_states( - set(HVAC_MODES) - {HVACMode.OFF}, + DOMAIN, + { + STATE_ON, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.HEAT_COOL, + HVACMode.AUTO, + HVACMode.FAN_ONLY, + }, + STATE_ON, STATE_OFF, ) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 5c7139d6290..ac9c0384dea 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -46,10 +46,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import group as group_pre_import # noqa: F401 +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -DOMAIN = "cover" SCAN_INTERVAL = timedelta(seconds=15) ENTITY_ID_FORMAT = DOMAIN + ".{}" diff --git a/homeassistant/components/cover/const.py b/homeassistant/components/cover/const.py new file mode 100644 index 00000000000..dd3e8b435c9 --- /dev/null +++ b/homeassistant/components/cover/const.py @@ -0,0 +1,3 @@ +"""Constants for cover entity platform.""" + +DOMAIN = "cover" diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py index a4b682b84ff..8beb0b6837c 100644 --- a/homeassistant/components/cover/group.py +++ b/homeassistant/components/cover/group.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING from homeassistant.const import STATE_CLOSED, STATE_OPEN from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -15,4 +17,4 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" # On means open, Off means closed - registry.on_off_states({STATE_OPEN}, STATE_CLOSED) + registry.on_off_states(DOMAIN, {STATE_OPEN}, STATE_OPEN, STATE_CLOSED) diff --git a/homeassistant/components/device_tracker/group.py b/homeassistant/components/device_tracker/group.py index e1b93696aa9..1c28887c2ca 100644 --- a/homeassistant/components/device_tracker/group.py +++ b/homeassistant/components/device_tracker/group.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -14,4 +16,4 @@ def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.on_off_states({STATE_HOME}, STATE_NOT_HOME) + registry.on_off_states(DOMAIN, {STATE_HOME}, STATE_HOME, STATE_NOT_HOME) diff --git a/homeassistant/components/group/registry.py b/homeassistant/components/group/registry.py index 6cdb929d60c..9ddf7c0b409 100644 --- a/homeassistant/components/group/registry.py +++ b/homeassistant/components/group/registry.py @@ -2,8 +2,7 @@ from __future__ import annotations -from contextvars import ContextVar -from typing import Protocol +from typing import TYPE_CHECKING, Protocol from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback @@ -13,12 +12,13 @@ from homeassistant.helpers.integration_platform import ( from .const import DOMAIN, REG_KEY -current_domain: ContextVar[str] = ContextVar("current_domain") +if TYPE_CHECKING: + from .entity import Group async def async_setup(hass: HomeAssistant) -> None: """Set up the Group integration registry of integration platforms.""" - hass.data[REG_KEY] = GroupIntegrationRegistry() + hass.data[REG_KEY] = GroupIntegrationRegistry(hass) await async_process_integration_platforms( hass, DOMAIN, _process_group_platform, wait_for_platforms=True @@ -39,7 +39,6 @@ def _process_group_platform( hass: HomeAssistant, domain: str, platform: GroupProtocol ) -> None: """Process a group platform.""" - current_domain.set(domain) registry: GroupIntegrationRegistry = hass.data[REG_KEY] platform.async_describe_on_off_states(hass, registry) @@ -47,24 +46,31 @@ def _process_group_platform( class GroupIntegrationRegistry: """Class to hold a registry of integrations.""" - def __init__(self) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Imitialize registry.""" + self.hass = hass self.on_off_mapping: dict[str, str] = {STATE_ON: STATE_OFF} self.off_on_mapping: dict[str, str] = {STATE_OFF: STATE_ON} self.on_states_by_domain: dict[str, set[str]] = {} self.exclude_domains: set[str] = set() + self.state_group_mapping: dict[str, tuple[str, str]] = {} + self.group_entities: set[Group] = set() - def exclude_domain(self) -> None: + @callback + def exclude_domain(self, domain: str) -> None: """Exclude the current domain.""" - self.exclude_domains.add(current_domain.get()) + self.exclude_domains.add(domain) - def on_off_states(self, on_states: set, off_state: str) -> None: + @callback + def on_off_states( + self, domain: str, on_states: set[str], default_on_state: str, off_state: str + ) -> None: """Register on and off states for the current domain.""" for on_state in on_states: if on_state not in self.on_off_mapping: self.on_off_mapping[on_state] = off_state if len(on_states) == 1 and off_state not in self.off_on_mapping: - self.off_on_mapping[off_state] = list(on_states)[0] + self.off_on_mapping[off_state] = default_on_state - self.on_states_by_domain[current_domain.get()] = set(on_states) + self.on_states_by_domain[domain] = on_states diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index 10c1526c5bb..bdd65868e62 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -44,13 +44,13 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType, StateType from . import group as group_pre_import # noqa: F401 +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" CONF_DEFAULT_CODE = "default_code" -DOMAIN = "lock" SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + ".{}" diff --git a/homeassistant/components/lock/const.py b/homeassistant/components/lock/const.py new file mode 100644 index 00000000000..1370a26ab36 --- /dev/null +++ b/homeassistant/components/lock/const.py @@ -0,0 +1,3 @@ +"""Constants for the lock entity platform.""" + +DOMAIN = "lock" diff --git a/homeassistant/components/lock/group.py b/homeassistant/components/lock/group.py index 99109e852f6..20aaed2b39a 100644 --- a/homeassistant/components/lock/group.py +++ b/homeassistant/components/lock/group.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -14,4 +16,4 @@ def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.on_off_states({STATE_UNLOCKED}, STATE_LOCKED) + registry.on_off_states(DOMAIN, {STATE_UNLOCKED}, STATE_UNLOCKED, STATE_LOCKED) diff --git a/homeassistant/components/media_player/group.py b/homeassistant/components/media_player/group.py index f4d465922af..1987ecf3470 100644 --- a/homeassistant/components/media_player/group.py +++ b/homeassistant/components/media_player/group.py @@ -11,6 +11,8 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -21,5 +23,13 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" registry.on_off_states( - {STATE_ON, STATE_PAUSED, STATE_PLAYING, STATE_IDLE}, STATE_OFF + DOMAIN, + { + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, + STATE_IDLE, + }, + STATE_ON, + STATE_OFF, ) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 4f86654a7d3..175a206b38f 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -55,6 +55,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import group as group_pre_import # noqa: F401 +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -66,8 +67,6 @@ CONF_DEVICE_TRACKERS = "device_trackers" CONF_USER_ID = "user_id" CONF_PICTURE = "picture" -DOMAIN = "person" - STORAGE_KEY = DOMAIN STORAGE_VERSION = 2 # Device tracker states to ignore diff --git a/homeassistant/components/person/const.py b/homeassistant/components/person/const.py new file mode 100644 index 00000000000..dbd228b333e --- /dev/null +++ b/homeassistant/components/person/const.py @@ -0,0 +1,3 @@ +"""Constants for the person entity platform.""" + +DOMAIN = "person" diff --git a/homeassistant/components/person/group.py b/homeassistant/components/person/group.py index e1b93696aa9..1c28887c2ca 100644 --- a/homeassistant/components/person/group.py +++ b/homeassistant/components/person/group.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING from homeassistant.const import STATE_HOME, STATE_NOT_HOME from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -14,4 +16,4 @@ def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.on_off_states({STATE_HOME}, STATE_NOT_HOME) + registry.on_off_states(DOMAIN, {STATE_HOME}, STATE_HOME, STATE_NOT_HOME) diff --git a/homeassistant/components/plant/group.py b/homeassistant/components/plant/group.py index 96d4166fe1f..abd24a2c23f 100644 --- a/homeassistant/components/plant/group.py +++ b/homeassistant/components/plant/group.py @@ -5,6 +5,8 @@ from typing import TYPE_CHECKING from homeassistant.const import STATE_OK, STATE_PROBLEM from homeassistant.core import HomeAssistant, callback +from .const import DOMAIN + if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry @@ -14,4 +16,4 @@ def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.on_off_states({STATE_PROBLEM}, STATE_OK) + registry.on_off_states(DOMAIN, {STATE_PROBLEM}, STATE_PROBLEM, STATE_OK) diff --git a/homeassistant/components/sensor/group.py b/homeassistant/components/sensor/group.py index 13a70cc4b6b..2bc4a122fdc 100644 --- a/homeassistant/components/sensor/group.py +++ b/homeassistant/components/sensor/group.py @@ -7,10 +7,12 @@ from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry +from .const import DOMAIN + @callback def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.exclude_domain() + registry.exclude_domain(DOMAIN) diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 4f5b6066dbd..fab26ebc8c5 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -36,11 +36,10 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from . import group as group_pre_import # noqa: F401 -from .const import STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING +from .const import DOMAIN, STATE_CLEANING, STATE_DOCKED, STATE_ERROR, STATE_RETURNING _LOGGER = logging.getLogger(__name__) -DOMAIN = "vacuum" ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=20) diff --git a/homeassistant/components/vacuum/const.py b/homeassistant/components/vacuum/const.py index f623d313b1a..af1558f8570 100644 --- a/homeassistant/components/vacuum/const.py +++ b/homeassistant/components/vacuum/const.py @@ -1,5 +1,7 @@ """Support for vacuum cleaner robots (botvacs).""" +DOMAIN = "vacuum" + STATE_CLEANING = "cleaning" STATE_DOCKED = "docked" STATE_RETURNING = "returning" diff --git a/homeassistant/components/vacuum/group.py b/homeassistant/components/vacuum/group.py index 3e874ec22e7..f8cd790e623 100644 --- a/homeassistant/components/vacuum/group.py +++ b/homeassistant/components/vacuum/group.py @@ -7,7 +7,8 @@ from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry -from .const import STATE_CLEANING, STATE_ERROR, STATE_RETURNING + +from .const import DOMAIN, STATE_CLEANING, STATE_ERROR, STATE_RETURNING @callback @@ -16,5 +17,13 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" registry.on_off_states( - {STATE_CLEANING, STATE_ON, STATE_RETURNING, STATE_ERROR}, STATE_OFF + DOMAIN, + { + STATE_ON, + STATE_CLEANING, + STATE_RETURNING, + STATE_ERROR, + }, + STATE_ON, + STATE_OFF, ) diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index ad0149919dc..6ea0a2bac6a 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -44,12 +44,11 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.util.unit_conversion import TemperatureConverter from . import group as group_pre_import # noqa: F401 +from .const import DOMAIN DEFAULT_MIN_TEMP = 110 DEFAULT_MAX_TEMP = 140 -DOMAIN = "water_heater" - ENTITY_ID_FORMAT = DOMAIN + ".{}" SCAN_INTERVAL = timedelta(seconds=60) diff --git a/homeassistant/components/water_heater/const.py b/homeassistant/components/water_heater/const.py index 5bf0816348c..cb316bd4fd9 100644 --- a/homeassistant/components/water_heater/const.py +++ b/homeassistant/components/water_heater/const.py @@ -1,5 +1,7 @@ """Support for water heater devices.""" +DOMAIN = "water_heater" + STATE_ECO = "eco" STATE_ELECTRIC = "electric" STATE_PERFORMANCE = "performance" diff --git a/homeassistant/components/water_heater/group.py b/homeassistant/components/water_heater/group.py index 72347c8a442..f74bf8a9ae4 100644 --- a/homeassistant/components/water_heater/group.py +++ b/homeassistant/components/water_heater/group.py @@ -2,12 +2,14 @@ from typing import TYPE_CHECKING -from homeassistant.const import STATE_OFF +from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry + from .const import ( + DOMAIN, STATE_ECO, STATE_ELECTRIC, STATE_GAS, @@ -23,7 +25,9 @@ def async_describe_on_off_states( ) -> None: """Describe group on off states.""" registry.on_off_states( + DOMAIN, { + STATE_ON, STATE_ECO, STATE_ELECTRIC, STATE_PERFORMANCE, @@ -31,5 +35,6 @@ def async_describe_on_off_states( STATE_HEAT_PUMP, STATE_GAS, }, + STATE_ON, STATE_OFF, ) diff --git a/homeassistant/components/weather/group.py b/homeassistant/components/weather/group.py index 13a70cc4b6b..2bc4a122fdc 100644 --- a/homeassistant/components/weather/group.py +++ b/homeassistant/components/weather/group.py @@ -7,10 +7,12 @@ from homeassistant.core import HomeAssistant, callback if TYPE_CHECKING: from homeassistant.components.group import GroupIntegrationRegistry +from .const import DOMAIN + @callback def async_describe_on_off_states( hass: HomeAssistant, registry: "GroupIntegrationRegistry" ) -> None: """Describe group on off states.""" - registry.exclude_domain() + registry.exclude_domain(DOMAIN) From 7a4aa3c40c7ae01ed35c57c5ee05d84bc6a7c2cb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 Apr 2024 17:34:27 +0200 Subject: [PATCH 075/272] Fix Netatmo indoor sensor (#116342) * Debug netatmo indoor sensor * Debug netatmo indoor sensor * Fix --- homeassistant/components/netatmo/sensor.py | 5 ++++- .../components/netatmo/snapshots/test_sensor.ambr | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index fd40bbf88b6..7d99ef9d32c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -529,7 +529,10 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return self.device.reachable or False + return ( + self.device.reachable + or getattr(self.device, self.entity_description.netatmo_name) is not None + ) @callback def async_update_callback(self) -> None: diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 0684956adb8..6ab1e4b1e1a 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -901,13 +901,15 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', 'friendly_name': 'Bedroom Reachability', + 'latitude': 13.377726, + 'longitude': 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_reachability', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'False', }) # --- # name: test_entity[sensor.bedroom_temperature-entry] @@ -1050,13 +1052,15 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', 'friendly_name': 'Bedroom Wi-Fi', + 'latitude': 13.377726, + 'longitude': 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_wi_fi', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'High', }) # --- # name: test_entity[sensor.bureau_modulate_battery-entry] @@ -6692,7 +6696,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '27', }) # --- # name: test_entity[sensor.villa_outdoor_humidity-entry] @@ -6791,7 +6795,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'High', }) # --- # name: test_entity[sensor.villa_outdoor_reachability-entry] @@ -6838,7 +6842,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'False', }) # --- # name: test_entity[sensor.villa_outdoor_temperature-entry] From ddf58b690512923ae5c868fcdc17e3439fa5e3eb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 10:36:03 -0500 Subject: [PATCH 076/272] Fix homeassistant_alerts delaying shutdown (#116340) --- homeassistant/components/homeassistant_alerts/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index ef5e330699a..5b5e758fba4 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -87,7 +87,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not coordinator.last_update_success: return - hass.async_create_task(async_update_alerts(), eager_start=True) + hass.async_create_background_task( + async_update_alerts(), "homeassistant_alerts update", eager_start=True + ) coordinator = AlertUpdateCoordinator(hass) coordinator.async_add_listener(async_schedule_update_alerts) From 62ab67376fecf9d9d0d9d726df1d7b81cebbccc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 10:57:08 -0500 Subject: [PATCH 077/272] Fix bond update delaying shutdown when push updated are not available (#116344) If push updates are not available, bond could delay shutdown. The update task should have been marked as a background task --- homeassistant/components/bond/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index f547707d5f1..4495e76859d 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -128,7 +128,9 @@ class BondEntity(Entity): _FALLBACK_SCAN_INTERVAL, ) return - self.hass.async_create_task(self._async_update(), eager_start=True) + self.hass.async_create_background_task( + self._async_update(), f"{DOMAIN} {self.name} update", eager_start=True + ) async def _async_update(self) -> None: """Fetch via the API.""" From 9ca1d204b6c1316930a4dbcfa39a212dcf26e0f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 11:19:38 -0500 Subject: [PATCH 078/272] Fix shelly delaying shutdown (#116346) --- .../components/shelly/coordinator.py | 36 +++++++++++++++---- tests/components/shelly/test_binary_sensor.py | 10 +++--- tests/components/shelly/test_climate.py | 18 +++++----- tests/components/shelly/test_coordinator.py | 16 ++++----- tests/components/shelly/test_number.py | 10 +++--- tests/components/shelly/test_sensor.py | 18 +++++----- tests/components/shelly/test_update.py | 6 ++-- 7 files changed, 69 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index bd6686198ed..d3d7b86de11 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -361,7 +361,12 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): ) -> None: """Handle device update.""" if update_type is BlockUpdateType.ONLINE: - self.hass.async_create_task(self._async_device_connect(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_device_connect(), + "block device online", + eager_start=True, + ) elif update_type is BlockUpdateType.COAP_PERIODIC: self._push_update_failures = 0 ir.async_delete_issue( @@ -654,12 +659,24 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) -> None: """Handle device update.""" if update_type is RpcUpdateType.ONLINE: - self.hass.async_create_task(self._async_device_connect(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_device_connect(), + "rpc device online", + eager_start=True, + ) elif update_type is RpcUpdateType.INITIALIZED: - self.hass.async_create_task(self._async_connected(), eager_start=True) + self.entry.async_create_background_task( + self.hass, self._async_connected(), "rpc device init", eager_start=True + ) self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.hass.async_create_task(self._async_disconnected(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_disconnected(), + "rpc device disconnected", + eager_start=True, + ) elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: @@ -673,7 +690,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.hass.async_create_task(self._async_connected(), eager_start=True) + self.entry.async_create_task( + self.hass, self._async_connected(), eager_start=True + ) async def shutdown(self) -> None: """Shutdown the coordinator.""" @@ -756,4 +775,9 @@ async def async_reconnect_soon(hass: HomeAssistant, entry: ConfigEntry) -> None: and (entry_data := get_entry_data(hass).get(entry.entry_id)) and (coordinator := entry_data.rpc) ): - hass.async_create_task(coordinator.async_request_refresh(), eager_start=True) + entry.async_create_background_task( + hass, + coordinator.async_request_refresh(), + "reconnect soon", + eager_start=True, + ) diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 624eb82f060..524bc1e8ffc 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -145,7 +145,7 @@ async def test_block_sleeping_binary_sensor( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -181,7 +181,7 @@ async def test_block_restored_sleeping_binary_sensor( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -207,7 +207,7 @@ async def test_block_restored_sleeping_binary_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -275,7 +275,7 @@ async def test_rpc_sleeping_binary_sensor( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -346,7 +346,7 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 9946dd7640d..a70cdef3fb1 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -70,7 +70,7 @@ async def test_climate_hvac_mode( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Test initial hvac mode - off state = hass.states.get(ENTITY_ID) @@ -131,7 +131,7 @@ async def test_climate_set_temperature( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.OFF @@ -198,7 +198,7 @@ async def test_climate_set_preset_mode( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -284,7 +284,7 @@ async def test_block_restored_climate( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == HVACMode.OFF assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 @@ -355,7 +355,7 @@ async def test_block_restored_climate_us_customery( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "temp", 18.2) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == HVACMode.OFF assert hass.states.get(entity_id).attributes.get("temperature") == 39 @@ -457,7 +457,7 @@ async def test_block_set_mode_connection_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -482,7 +482,7 @@ async def test_block_set_mode_auth_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED @@ -540,7 +540,7 @@ async def test_block_restored_climate_auth_error( return_value={}, side_effect=InvalidAuthError ) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED @@ -567,7 +567,7 @@ async def test_device_not_calibrated( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_status = MOCK_STATUS_COAP.copy() mock_status["calibrated"] = False diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 9f251d1e008..1e581e156c5 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -224,7 +224,7 @@ async def test_block_sleeping_device_firmware_unsupported( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED assert ( @@ -299,7 +299,7 @@ async def test_block_sleeping_device_no_periodic_updates( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) == "22.1" @@ -542,7 +542,7 @@ async def test_rpc_update_entry_sleep_period( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.data["sleep_period"] == 600 @@ -550,7 +550,7 @@ async def test_rpc_update_entry_sleep_period( monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.data["sleep_period"] == 3600 @@ -575,14 +575,14 @@ async def test_rpc_sleeping_device_no_periodic_updates( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE @@ -599,7 +599,7 @@ async def test_rpc_sleeping_device_firmware_unsupported( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED assert ( @@ -765,7 +765,7 @@ async def test_rpc_update_entry_fw_ver( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.unique_id device = dev_reg.async_get_device( diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 99ad5709d29..0b9fee9e47f 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -44,7 +44,7 @@ async def test_block_number_update( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -99,7 +99,7 @@ async def test_block_restored_number( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -136,7 +136,7 @@ async def test_block_restored_number_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -156,7 +156,7 @@ async def test_block_number_set_value( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_block_device.reset_mock() await hass.services.async_call( @@ -217,7 +217,7 @@ async def test_block_set_value_auth_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 6151cac10ab..ceaa9b66b8d 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -165,7 +165,7 @@ async def test_block_sleeping_sensor( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -207,7 +207,7 @@ async def test_block_restored_sleeping_sensor( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -233,7 +233,7 @@ async def test_block_restored_sleeping_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -306,7 +306,7 @@ async def test_block_not_matched_restored_sleeping_sensor( ) monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "20.4" @@ -464,7 +464,7 @@ async def test_rpc_sleeping_sensor( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.9" @@ -503,7 +503,7 @@ async def test_rpc_restored_sleeping_sensor( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -539,7 +539,7 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -607,7 +607,7 @@ async def test_rpc_sleeping_update_entity_service( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "22.9" @@ -657,7 +657,7 @@ async def test_block_sleeping_update_entity_service( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 93b0f55c415..0f26fd14d12 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -352,7 +352,7 @@ async def test_rpc_sleeping_update( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -413,7 +413,7 @@ async def test_rpc_restored_sleeping_update( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -462,7 +462,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() From 3bcce2197c75375f9ab51efa60401161b862c049 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 11:36:01 -0500 Subject: [PATCH 079/272] Fix incorrect call to async_schedule_update_ha_state in command_line switch (#116347) --- homeassistant/components/command_line/switch.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index fee94424fa1..8a75276c8b4 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -191,12 +191,12 @@ class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() await self._update_entity_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() await self._update_entity_state() From 48b167807503df5bd0d0b1b04681346e49e2f760 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 28 Apr 2024 18:50:15 +0200 Subject: [PATCH 080/272] Add test helper to remove device (#116234) * Add test helper to remove device * Rename * Fix signature --- .../components/config/test_device_registry.py | 81 +++---------------- tests/conftest.py | 11 +++ tests/typing.py | 1 + 3 files changed, 21 insertions(+), 72 deletions(-) diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index f88ae42b98a..1b7eff84472 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -278,14 +278,7 @@ async def test_remove_config_entry_from_device( # Try removing a config entry from the device, it should fail because # async_remove_config_entry_device returns False - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_1.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -294,14 +287,7 @@ async def test_remove_config_entry_from_device( can_remove = True # Remove the 1st config entry - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_1.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) assert response["success"] assert response["result"]["config_entries"] == [entry_2.entry_id] @@ -312,14 +298,7 @@ async def test_remove_config_entry_from_device( } # Remove the 2nd config entry - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_2.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert response["result"] is None @@ -398,28 +377,14 @@ async def test_remove_config_entry_from_device_fails( assert device_entry.id != fake_device_id # Try removing a non existing config entry from the device - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": fake_entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, fake_entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown config entry" # Try removing a config entry which does not support removal from the device - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_1.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_1.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" @@ -428,28 +393,14 @@ async def test_remove_config_entry_from_device_fails( ) # Try removing a config entry from a device which does not exist - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_2.entry_id, - "device_id": fake_device_id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(fake_device_id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Unknown device" # Try removing a config entry from a device which it's not connected to - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_2.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert response["success"] assert set(response["result"]["config_entries"]) == { @@ -457,28 +408,14 @@ async def test_remove_config_entry_from_device_fails( entry_3.entry_id, } - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_2.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_2.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" assert response["error"]["message"] == "Config entry not in device" # Try removing a config entry which can't be loaded from a device - allowed - await ws_client.send_json_auto_id( - { - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry_3.entry_id, - "device_id": device_entry.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(device_entry.id, entry_3.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" diff --git a/tests/conftest.py b/tests/conftest.py index 4feae83798f..4852a41c061 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -856,10 +856,21 @@ def hass_ws_client( data["id"] = next(id_generator) return websocket.send_json(data) + async def _remove_device(device_id: str, config_entry_id: str) -> Any: + await _send_json_auto_id( + { + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + return await websocket.receive_json() + # wrap in client wrapped_websocket = cast(MockHAClientWebSocket, websocket) wrapped_websocket.client = client wrapped_websocket.send_json_auto_id = _send_json_auto_id + wrapped_websocket.remove_device = _remove_device return wrapped_websocket return create_client diff --git a/tests/typing.py b/tests/typing.py index 3e6a7cd4bc3..18824163fd2 100644 --- a/tests/typing.py +++ b/tests/typing.py @@ -20,6 +20,7 @@ class MockHAClientWebSocket(ClientWebSocketResponse): client: TestClient send_json_auto_id: Callable[[dict[str, Any]], Coroutine[Any, Any, None]] + remove_device: Callable[[str, str], Coroutine[Any, Any, Any]] ClientSessionGenerator = Callable[..., Coroutine[Any, Any, TestClient]] From ab2ea6100ca8c165f7d695e1a90c0c57b46cb389 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 12:11:08 -0500 Subject: [PATCH 081/272] Speed up singleton decorator so it can be used more places (#116292) --- homeassistant/bootstrap.py | 10 ++++++---- homeassistant/helpers/entity.py | 10 +++++----- homeassistant/helpers/singleton.py | 1 + homeassistant/helpers/translation.py | 9 +++++---- homeassistant/requirements.py | 9 +++------ homeassistant/setup.py | 14 +++++--------- 6 files changed, 25 insertions(+), 28 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cbc808eb0fa..8a77d438e84 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -90,7 +90,11 @@ from .helpers.system_info import async_get_system_info from .helpers.typing import ConfigType from .setup import ( BASE_PLATFORMS, - DATA_SETUP_STARTED, + # _setup_started is marked as protected to make it clear + # that it is not part of the public API and should not be used + # by integrations. It is only used for internal tracking of + # which integrations are being set up. + _setup_started, async_get_setup_timings, async_notify_setup_error, async_set_domains_to_be_loaded, @@ -913,9 +917,7 @@ async def _async_set_up_integrations( hass: core.HomeAssistant, config: dict[str, Any] ) -> None: """Set up all the integrations.""" - setup_started: dict[tuple[str, str | None], float] = {} - hass.data[DATA_SETUP_STARTED] = setup_started - watcher = _WatchPendingSetups(hass, setup_started) + watcher = _WatchPendingSetups(hass, _setup_started(hass)) watcher.async_start() domains_to_setup, integration_cache = await _async_resolve_domains_to_setup( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 07d5410f3f2..cf493b5477e 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -66,7 +66,7 @@ from homeassistant.loader import async_suggest_report_issue, bind_hass from homeassistant.util import ensure_unique_string, slugify from homeassistant.util.frozen_dataclass_compat import FrozenOrThawed -from . import device_registry as dr, entity_registry as er +from . import device_registry as dr, entity_registry as er, singleton from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData from .event import ( async_track_device_registry_updated_event, @@ -98,15 +98,15 @@ CONTEXT_RECENT_TIME_SECONDS = 5 # Time that a context is considered recent @callback def async_setup(hass: HomeAssistant) -> None: """Set up entity sources.""" - hass.data[DATA_ENTITY_SOURCE] = {} + entity_sources(hass) @callback @bind_hass +@singleton.singleton(DATA_ENTITY_SOURCE) def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources.""" - _entity_sources: dict[str, EntityInfo] = hass.data[DATA_ENTITY_SOURCE] - return _entity_sources + return {} def generate_entity_id( @@ -1486,7 +1486,7 @@ class Entity( # The check for self.platform guards against integrations not using an # EntityComponent and can be removed in HA Core 2024.1 if self.platform: - self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id) + entity_sources(self.hass).pop(self.entity_id) @callback def _async_registry_updated( diff --git a/homeassistant/helpers/singleton.py b/homeassistant/helpers/singleton.py index 91e7a671b69..bf9b6019164 100644 --- a/homeassistant/helpers/singleton.py +++ b/homeassistant/helpers/singleton.py @@ -25,6 +25,7 @@ def singleton(data_key: str) -> Callable[[_FuncType[_T]], _FuncType[_T]]: """Wrap a function with caching logic.""" if not asyncio.iscoroutinefunction(func): + @functools.lru_cache(maxsize=1) @bind_hass @functools.wraps(func) def wrapped(hass: HomeAssistant) -> _T: diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 377826b7edb..182747ec415 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -24,6 +24,8 @@ from homeassistant.loader import ( ) from homeassistant.util.json import load_json +from . import singleton + _LOGGER = logging.getLogger(__name__) TRANSLATION_FLATTEN_CACHE = "translation_flatten_cache" @@ -370,11 +372,10 @@ def async_get_cached_translations( ) -@callback +@singleton.singleton(TRANSLATION_FLATTEN_CACHE) def _async_get_translations_cache(hass: HomeAssistant) -> _TranslationCache: """Return the translation cache.""" - cache: _TranslationCache = hass.data[TRANSLATION_FLATTEN_CACHE] - return cache + return _TranslationCache(hass) @callback @@ -385,7 +386,7 @@ def async_setup(hass: HomeAssistant) -> None: """ cache = _TranslationCache(hass) current_language = hass.config.language - hass.data[TRANSLATION_FLATTEN_CACHE] = cache + _async_get_translations_cache(hass) @callback def _async_load_translations_filter(event_data: Mapping[str, Any]) -> bool: diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index e282ced90ac..e29e0c34ece 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -12,6 +12,7 @@ from packaging.requirements import Requirement from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers import singleton from .helpers.typing import UNDEFINED, UndefinedType from .loader import Integration, IntegrationNotFound, async_get_integration from .util import package as pkg_util @@ -72,14 +73,10 @@ async def async_load_installed_versions( @callback +@singleton.singleton(DATA_REQUIREMENTS_MANAGER) def _async_get_manager(hass: HomeAssistant) -> RequirementsManager: """Get the requirements manager.""" - if DATA_REQUIREMENTS_MANAGER in hass.data: - manager: RequirementsManager = hass.data[DATA_REQUIREMENTS_MANAGER] - return manager - - manager = hass.data[DATA_REQUIREMENTS_MANAGER] = RequirementsManager(hass) - return manager + return RequirementsManager(hass) @callback diff --git a/homeassistant/setup.py b/homeassistant/setup.py index fab70e31d9d..894fc0eeb73 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -29,7 +29,7 @@ from .core import ( callback, ) from .exceptions import DependencyError, HomeAssistantError -from .helpers import translation +from .helpers import singleton, translation from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType from .util.async_ import create_eager_task @@ -671,13 +671,12 @@ class SetupPhases(StrEnum): """Wait time for the packages to import.""" +@singleton.singleton(DATA_SETUP_STARTED) def _setup_started( hass: core.HomeAssistant, ) -> dict[tuple[str, str | None], float]: """Return the setup started dict.""" - if DATA_SETUP_STARTED not in hass.data: - hass.data[DATA_SETUP_STARTED] = {} - return hass.data[DATA_SETUP_STARTED] # type: ignore[no-any-return] + return {} @contextlib.contextmanager @@ -717,15 +716,12 @@ def async_pause_setup( ) +@singleton.singleton(DATA_SETUP_TIME) def _setup_times( hass: core.HomeAssistant, ) -> defaultdict[str, defaultdict[str | None, defaultdict[SetupPhases, float]]]: """Return the setup timings default dict.""" - if DATA_SETUP_TIME not in hass.data: - hass.data[DATA_SETUP_TIME] = defaultdict( - lambda: defaultdict(lambda: defaultdict(float)) - ) - return hass.data[DATA_SETUP_TIME] # type: ignore[no-any-return] + return defaultdict(lambda: defaultdict(lambda: defaultdict(float))) @contextlib.contextmanager From 48d620ce94f1e6d68df455dccd5d3c11a9677774 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 12:54:53 -0500 Subject: [PATCH 082/272] Fix another case of homeassistant_alerts delaying shutdown (#116352) --- homeassistant/components/homeassistant_alerts/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 5b5e758fba4..b33bfe5ed1e 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -101,6 +101,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cooldown=COMPONENT_LOADED_COOLDOWN, immediate=False, function=coordinator.async_refresh, + background=True, ) @callback From 66a94304102e20b158c8ed3b3717255b3aff7e45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 12:55:17 -0500 Subject: [PATCH 083/272] Fix incorrect call to async_schedule_update_ha_state in generic_hygrostat (#116349) --- homeassistant/components/generic_hygrostat/humidifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index 32ad34773bd..dea614d92f2 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -412,7 +412,7 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): else: self._attr_action = HumidifierAction.IDLE - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _async_update_humidity(self, humidity: str) -> None: """Update hygrostat with latest state from sensor.""" From cdfd0aa7d4ac9bf46d151392e612e90d5d6f33da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 12:55:28 -0500 Subject: [PATCH 084/272] Fix incorrect call to async_schedule_update_ha_state in manual_mqtt (#116348) --- homeassistant/components/manual_mqtt/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index db81825d7b5..0bb7c57599a 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -355,7 +355,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._async_validate_code(code, STATE_ALARM_DISARMED) self._state = STATE_ALARM_DISARMED self._state_ts = dt_util.utcnow() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" From e215270c0564e80ef698ba83a0940b9cf539a715 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 16:30:19 -0500 Subject: [PATCH 085/272] Remove eager_start argument from internal _async_add_hass_job function (#116310) --- homeassistant/core.py | 32 +++++++++----------------------- tests/test_core.py | 38 ++++++++++++++------------------------ 2 files changed, 23 insertions(+), 47 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 7a5d2b22862..9cab560cd2f 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -577,9 +577,7 @@ class HomeAssistant: if TYPE_CHECKING: target = cast(Callable[[*_Ts], Any], target) self.loop.call_soon_threadsafe( - functools.partial( - self._async_add_hass_job, HassJob(target), *args, eager_start=True - ) + functools.partial(self._async_add_hass_job, HassJob(target), *args) ) @overload @@ -650,7 +648,7 @@ class HomeAssistant: # https://github.com/home-assistant/core/pull/71960 if TYPE_CHECKING: target = cast(Callable[[*_Ts], Coroutine[Any, Any, _R] | _R], target) - return self._async_add_hass_job(HassJob(target), *args, eager_start=eager_start) + return self._async_add_hass_job(HassJob(target), *args) @overload @callback @@ -700,9 +698,7 @@ class HomeAssistant: error_if_core=False, ) - return self._async_add_hass_job( - hassjob, *args, eager_start=eager_start, background=background - ) + return self._async_add_hass_job(hassjob, *args, background=background) @overload @callback @@ -710,7 +706,6 @@ class HomeAssistant: self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any, - eager_start: bool = False, background: bool = False, ) -> asyncio.Future[_R] | None: ... @@ -720,7 +715,6 @@ class HomeAssistant: self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, - eager_start: bool = False, background: bool = False, ) -> asyncio.Future[_R] | None: ... @@ -729,7 +723,6 @@ class HomeAssistant: self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any, - eager_start: bool = False, background: bool = False, ) -> asyncio.Future[_R] | None: """Add a HassJob from within the event loop. @@ -751,16 +744,11 @@ class HomeAssistant: hassjob.target = cast( Callable[..., Coroutine[Any, Any, _R]], hassjob.target ) - # Use loop.create_task - # to avoid the extra function call in asyncio.create_task. - if eager_start: - task = create_eager_task( - hassjob.target(*args), name=hassjob.name, loop=self.loop - ) - if task.done(): - return task - else: - task = self.loop.create_task(hassjob.target(*args), name=hassjob.name) + task = create_eager_task( + hassjob.target(*args), name=hassjob.name, loop=self.loop + ) + if task.done(): + return task elif hassjob.job_type is HassJobType.Callback: if TYPE_CHECKING: hassjob.target = cast(Callable[..., _R], hassjob.target) @@ -914,9 +902,7 @@ class HomeAssistant: hassjob.target(*args) return None - return self._async_add_hass_job( - hassjob, *args, eager_start=True, background=background - ) + return self._async_add_hass_job(hassjob, *args, background=background) @overload @callback diff --git a/tests/test_core.py b/tests/test_core.py index a553d5bbbed..123054540b1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -109,9 +109,7 @@ async def test_async_add_hass_job_eager_start_coro_suspends( async def job_that_suspends(): await asyncio.sleep(0) - task = hass._async_add_hass_job( - ha.HassJob(ha.callback(job_that_suspends)), eager_start=True - ) + task = hass._async_add_hass_job(ha.HassJob(ha.callback(job_that_suspends))) assert not task.done() assert task in hass._tasks await task @@ -247,7 +245,7 @@ async def test_async_add_hass_job_eager_start(hass: HomeAssistant) -> None: job = ha.HassJob(mycoro, "named coro") assert "named coro" in str(job) assert job.name == "named coro" - task = ha.HomeAssistant._async_add_hass_job(hass, job, eager_start=True) + task = ha.HomeAssistant._async_add_hass_job(hass, job) assert "named coro" in str(task) @@ -263,19 +261,6 @@ async def test_async_add_hass_job_schedule_partial_callback() -> None: assert len(hass.add_job.mock_calls) == 0 -async def test_async_add_hass_job_schedule_coroutinefunction() -> None: - """Test that we schedule coroutines and add jobs to the job pool.""" - hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) - - async def job(): - pass - - ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(job)) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 - - async def test_async_add_hass_job_schedule_corofunction_eager_start() -> None: """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) @@ -287,15 +272,15 @@ async def test_async_add_hass_job_schedule_corofunction_eager_start() -> None: "homeassistant.core.create_eager_task", wraps=create_eager_task ) as mock_create_eager_task: hass_job = ha.HassJob(job) - task = ha.HomeAssistant._async_add_hass_job(hass, hass_job, eager_start=True) + task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) assert len(hass.loop.call_soon.mock_calls) == 0 assert len(hass.add_job.mock_calls) == 0 assert mock_create_eager_task.mock_calls await task -async def test_async_add_hass_job_schedule_partial_coroutinefunction() -> None: - """Test that we schedule partial coros and add jobs to the job pool.""" +async def test_async_add_hass_job_schedule_partial_corofunction_eager_start() -> None: + """Test that we schedule coroutines and add jobs to the job pool.""" hass = MagicMock(loop=MagicMock(wraps=asyncio.get_running_loop())) async def job(): @@ -303,10 +288,15 @@ async def test_async_add_hass_job_schedule_partial_coroutinefunction() -> None: partial = functools.partial(job) - ha.HomeAssistant._async_add_hass_job(hass, ha.HassJob(partial)) - assert len(hass.loop.call_soon.mock_calls) == 0 - assert len(hass.loop.create_task.mock_calls) == 1 - assert len(hass.add_job.mock_calls) == 0 + with patch( + "homeassistant.core.create_eager_task", wraps=create_eager_task + ) as mock_create_eager_task: + hass_job = ha.HassJob(partial) + task = ha.HomeAssistant._async_add_hass_job(hass, hass_job) + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.add_job.mock_calls) == 0 + assert mock_create_eager_task.mock_calls + await task async def test_async_add_job_add_hass_threaded_job_to_pool() -> None: From b8ddf51e28fcd36e94130a5056bc4fc398410ddf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 17:17:26 -0500 Subject: [PATCH 086/272] Avoid creating tasks to update universal media player (#116350) * Avoid creating tasks to update universal media player Nothing was being awaited in async_update. This entity has polling disabled and there was no reason to implement manual updates since the state is always coming from other entities * manual update --- .../components/universal/media_player.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 5deebc4103b..8356e289094 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -162,7 +162,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): ): """Initialize the Universal media device.""" self.hass = hass - self._name = config.get(CONF_NAME) + self._attr_name = config.get(CONF_NAME) self._children = config.get(CONF_CHILDREN) self._active_child_template = config.get(CONF_ACTIVE_CHILD_TEMPLATE) self._active_child_template_result = None @@ -189,7 +189,8 @@ class UniversalMediaPlayer(MediaPlayerEntity): ) -> None: """Update ha state when dependencies update.""" self.async_set_context(event.context) - self.async_schedule_update_ha_state(True) + self._async_update() + self.async_write_ha_state() @callback def _async_on_template_update( @@ -213,7 +214,8 @@ class UniversalMediaPlayer(MediaPlayerEntity): if event: self.async_set_context(event.context) - self.async_schedule_update_ha_state(True) + self._async_update() + self.async_write_ha_state() track_templates: list[TrackTemplate] = [] if self._state_template: @@ -306,11 +308,6 @@ class UniversalMediaPlayer(MediaPlayerEntity): return None - @property - def name(self): - """Return the name of universal player.""" - return self._name - @property def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" @@ -659,7 +656,8 @@ class UniversalMediaPlayer(MediaPlayerEntity): return await entity.async_browse_media(media_content_type, media_content_id) raise NotImplementedError - async def async_update(self) -> None: + @callback + def _async_update(self) -> None: """Update state in HA.""" if self._active_child_template_result: self._child_state = self.hass.states.get(self._active_child_template_result) @@ -676,3 +674,7 @@ class UniversalMediaPlayer(MediaPlayerEntity): self._child_state = child_state else: self._child_state = child_state + + async def async_update(self) -> None: + """Manual update from API.""" + self._async_update() From 164403de207a0c4a5ee709a6c1d7c22ecc18c4de Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 17:29:00 -0500 Subject: [PATCH 087/272] Add thread safety checks to async_create_task (#116339) * Add thread safety checks to async_create_task Calling async_create_task from a thread almost always results in an fast crash. Since most internals are using async_create_background_task or other task APIs, and this is the one integrations seem to get wrong the most, add a thread safety check here * Add thread safety checks to async_create_task Calling async_create_task from a thread almost always results in an fast crash. Since most internals are using async_create_background_task or other task APIs, and this is the one integrations seem to get wrong the most, add a thread safety check here * missed one * Update homeassistant/core.py * fix mocks * one more internal * more places where internal can be used * more places where internal can be used * more places where internal can be used * internal one more place since this is high volume and was already eager_start --- homeassistant/bootstrap.py | 2 +- homeassistant/config_entries.py | 4 +- homeassistant/core.py | 37 ++++++++++++++++++- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/entity_component.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/integration_platform.py | 4 +- homeassistant/helpers/intent.py | 2 +- homeassistant/helpers/restore_state.py | 4 +- homeassistant/helpers/script.py | 4 +- homeassistant/helpers/storage.py | 2 +- homeassistant/setup.py | 2 +- tests/common.py | 8 ++-- tests/test_core.py | 18 +++++++-- 14 files changed, 70 insertions(+), 23 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 8a77d438e84..741947a2e23 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -735,7 +735,7 @@ async def async_setup_multi_components( # to wait to be imported, and the sooner we can get the base platforms # loaded the sooner we can start loading the rest of the integrations. futures = { - domain: hass.async_create_task( + domain: hass.async_create_task_internal( async_setup_component(hass, domain, config), f"setup component {domain}", eager_start=True, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 73e1d8debd6..619b2a4b48a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1087,7 +1087,7 @@ class ConfigEntry: target: target to call. """ - task = hass.async_create_task( + task = hass.async_create_task_internal( target, f"{name} {self.title} {self.domain} {self.entry_id}", eager_start ) if eager_start and task.done(): @@ -1643,7 +1643,7 @@ class ConfigEntries: # starting a new flow with the 'unignore' step. If the integration doesn't # implement async_step_unignore then this will be a no-op. if entry.source == SOURCE_IGNORE: - self.hass.async_create_task( + self.hass.async_create_task_internal( self.hass.config_entries.flow.async_init( entry.domain, context={"source": SOURCE_UNIGNORE}, diff --git a/homeassistant/core.py b/homeassistant/core.py index 9cab560cd2f..fe16640a572 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -773,7 +773,9 @@ class HomeAssistant: target: target to call. """ self.loop.call_soon_threadsafe( - functools.partial(self.async_create_task, target, name, eager_start=True) + functools.partial( + self.async_create_task_internal, target, name, eager_start=True + ) ) @callback @@ -788,6 +790,37 @@ class HomeAssistant: This method must be run in the event loop. If you are using this in your integration, use the create task methods on the config entry instead. + target: target to call. + """ + # We turned on asyncio debug in April 2024 in the dev containers + # in the hope of catching some of the issues that have been + # reported. It will take a while to get all the issues fixed in + # custom components. + # + # In 2025.5 we should guard the `verify_event_loop_thread` + # check with a check for the `hass.config.debug` flag being set as + # long term we don't want to be checking this in production + # environments since it is a performance hit. + self.verify_event_loop_thread("async_create_task") + return self.async_create_task_internal(target, name, eager_start) + + @callback + def async_create_task_internal( + self, + target: Coroutine[Any, Any, _R], + name: str | None = None, + eager_start: bool = True, + ) -> asyncio.Task[_R]: + """Create a task from within the event loop, internal use only. + + This method is intended to only be used by core internally + and should not be considered a stable API. We will make + breaking change to this function in the future and it + should not be used in integrations. + + This method must be run in the event loop. If you are using this in your + integration, use the create task methods on the config entry instead. + target: target to call. """ if eager_start: @@ -2683,7 +2716,7 @@ class ServiceRegistry: coro = self._execute_service(handler, service_call) if not blocking: - self._hass.async_create_task( + self._hass.async_create_task_internal( self._run_service_call_catch_exceptions(coro, service_call), f"service call background {service_call.domain}.{service_call.service}", eager_start=True, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cf493b5477e..cc8374350cc 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1497,7 +1497,7 @@ class Entity( is_remove = action == "remove" self._removed_from_registry = is_remove if action == "update" or is_remove: - self.hass.async_create_task( + self.hass.async_create_task_internal( self._async_process_registry_update_or_remove(event), eager_start=True ) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index f467b5683a9..eb54d83e1dd 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -146,7 +146,7 @@ class EntityComponent(Generic[_EntityT]): # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: - self.hass.async_create_task( + self.hass.async_create_task_internal( self.async_setup_platform(p_type, p_config), f"EntityComponent setup platform {p_type} {self.domain}", eager_start=True, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2b9a5d436ed..f95c0a0b66a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -477,7 +477,7 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform async.""" - task = self.hass.async_create_task( + task = self.hass.async_create_task_internal( self.async_add_entities(new_entities, update_before_add=update_before_add), f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", eager_start=True, diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index be525b384e0..fbd26019b64 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -85,7 +85,7 @@ def _async_integration_platform_component_loaded( # At least one of the platforms is not loaded, we need to load them # so we have to fall back to creating a task. - hass.async_create_task( + hass.async_create_task_internal( _async_process_integration_platforms_for_component( hass, integration, platforms_that_exist, integration_platforms_by_name ), @@ -206,7 +206,7 @@ async def async_process_integration_platforms( # We use hass.async_create_task instead of asyncio.create_task because # we want to make sure that startup waits for the task to complete. # - future = hass.async_create_task( + future = hass.async_create_task_internal( _async_process_integration_platforms( hass, platform_name, top_level_components.copy(), process_job ), diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0ddf4a1e329..119142ec14a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -659,7 +659,7 @@ class DynamicServiceIntentHandler(IntentHandler): ) await self._run_then_background( - hass.async_create_task( + hass.async_create_task_internal( hass.services.async_call( domain, service, diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 40c898fe1d2..2b3afc2f57b 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -236,7 +236,9 @@ class RestoreStateData: # Dump the initial states now. This helps minimize the risk of having # old states loaded by overwriting the last states once Home Assistant # has started and the old states have been read. - self.hass.async_create_task(_async_dump_states(), "RestoreStateData dump") + self.hass.async_create_task_internal( + _async_dump_states(), "RestoreStateData dump" + ) # Dump states periodically cancel_interval = async_track_time_interval( diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d739fbfef98..1bbe7749ff7 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -734,7 +734,7 @@ class _ScriptRun: ) trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( - self._hass.async_create_task( + self._hass.async_create_task_internal( self._hass.services.async_call( **params, blocking=True, @@ -1208,7 +1208,7 @@ class _ScriptRun: async def _async_run_script(self, script: Script) -> None: """Execute a script.""" result = await self._async_run_long_action( - self._hass.async_create_task( + self._hass.async_create_task_internal( script.async_run(self._variables, self._context), eager_start=True ) ) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index bf9d49b4f21..8c907dfa54a 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -468,7 +468,7 @@ class Store(Generic[_T]): # wrote. Reschedule the timer to the next write time. self._async_reschedule_delayed_write(self._next_write_time) return - self.hass.async_create_task( + self.hass.async_create_task_internal( self._async_callback_delayed_write(), eager_start=True ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 894fc0eeb73..5d562816a6f 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -600,7 +600,7 @@ def _async_when_setup( _LOGGER.exception("Error handling when_setup callback for %s", component) if component in hass.config.components: - hass.async_create_task( + hass.async_create_task_internal( when_setup(), f"when setup {component}", eager_start=True ) return diff --git a/tests/common.py b/tests/common.py index 7bb16ce5c54..8e220f59215 100644 --- a/tests/common.py +++ b/tests/common.py @@ -234,7 +234,7 @@ async def async_test_home_assistant( orig_async_add_job = hass.async_add_job orig_async_add_executor_job = hass.async_add_executor_job - orig_async_create_task = hass.async_create_task + orig_async_create_task_internal = hass.async_create_task_internal orig_tz = dt_util.DEFAULT_TIME_ZONE def async_add_job(target, *args, eager_start: bool = False): @@ -263,18 +263,18 @@ async def async_test_home_assistant( return orig_async_add_executor_job(target, *args) - def async_create_task(coroutine, name=None, eager_start=True): + def async_create_task_internal(coroutine, name=None, eager_start=True): """Create task.""" if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): fut = asyncio.Future() fut.set_result(None) return fut - return orig_async_create_task(coroutine, name, eager_start) + return orig_async_create_task_internal(coroutine, name, eager_start) hass.async_add_job = async_add_job hass.async_add_executor_job = async_add_executor_job - hass.async_create_task = async_create_task + hass.async_create_task_internal = async_create_task_internal hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} diff --git a/tests/test_core.py b/tests/test_core.py index 123054540b1..2dcd23db9a6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -319,7 +319,7 @@ async def test_async_create_task_schedule_coroutine() -> None: async def job(): pass - ha.HomeAssistant.async_create_task(hass, job(), eager_start=False) + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=False) assert len(hass.loop.call_soon.mock_calls) == 0 assert len(hass.loop.create_task.mock_calls) == 1 assert len(hass.add_job.mock_calls) == 0 @@ -332,7 +332,7 @@ async def test_async_create_task_eager_start_schedule_coroutine() -> None: async def job(): pass - ha.HomeAssistant.async_create_task(hass, job(), eager_start=True) + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) # Should create the task directly since 3.12 supports eager_start assert len(hass.loop.create_task.mock_calls) == 0 assert len(hass.add_job.mock_calls) == 0 @@ -345,7 +345,7 @@ async def test_async_create_task_schedule_coroutine_with_name() -> None: async def job(): pass - task = ha.HomeAssistant.async_create_task( + task = ha.HomeAssistant.async_create_task_internal( hass, job(), "named task", eager_start=False ) assert len(hass.loop.call_soon.mock_calls) == 0 @@ -3470,3 +3470,15 @@ async def test_async_remove_thread_safety(hass: HomeAssistant) -> None: await hass.async_add_executor_job( hass.services.async_remove, "test_domain", "test_service" ) + + +async def test_async_create_task_thread_safety(hass: HomeAssistant) -> None: + """Test async_create_task thread safety.""" + + async def _any_coro(): + pass + + with pytest.raises( + RuntimeError, match="Detected code that calls async_create_task from a thread." + ): + await hass.async_add_executor_job(hass.async_create_task, _any_coro) From 8c73c1e1a5a791854c61d57a666deea809237835 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Sun, 28 Apr 2024 19:02:10 -0700 Subject: [PATCH 088/272] Bump total_connect_client to 2024.4 (#116360) --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index d1afb01210d..a8b23041a39 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/totalconnect", "iot_class": "cloud_polling", "loggers": ["total_connect_client"], - "requirements": ["total-connect-client==2023.12.1"] + "requirements": ["total-connect-client==2024.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index d08193f4636..d4ce7689f42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2734,7 +2734,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2023.12.1 +total-connect-client==2024.4 # homeassistant.components.tplink_lte tp-connected==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6600adaea76..cb9cfa33151 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2111,7 +2111,7 @@ tololib==1.1.0 toonapi==0.3.0 # homeassistant.components.totalconnect -total-connect-client==2023.12.1 +total-connect-client==2024.4 # homeassistant.components.tplink_omada tplink-omada-client==1.3.12 From 381ffe6eed4ab3c19f671cb89b4abfab091473c2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 00:38:40 -0500 Subject: [PATCH 089/272] Use built-in aiohttp timeout instead of asyncio.timeout in media_player (#116364) * Use built-in aiohttp timeout instead of asyncio.timeout in media_player Avoids having two timeouts running to fetch images * fix mock --- homeassistant/components/media_player/__init__.py | 15 +++++++++------ tests/components/demo/test_media_player.py | 2 +- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 35e1b1cb71e..b90de95a489 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -17,6 +17,7 @@ import secrets from typing import Any, Final, Required, TypedDict, final from urllib.parse import quote, urlparse +import aiohttp from aiohttp import web from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE from aiohttp.typedefs import LooseHeaders @@ -1336,6 +1337,9 @@ async def websocket_browse_media( connection.send_result(msg["id"], result) +_FETCH_TIMEOUT = aiohttp.ClientTimeout(total=10) + + async def async_fetch_image( logger: logging.Logger, hass: HomeAssistant, url: str ) -> tuple[bytes | None, str | None]: @@ -1343,12 +1347,11 @@ async def async_fetch_image( content, content_type = (None, None) websession = async_get_clientsession(hass) with suppress(TimeoutError): - async with asyncio.timeout(10): - response = await websession.get(url) - if response.status == HTTPStatus.OK: - content = await response.read() - if content_type := response.headers.get(CONTENT_TYPE): - content_type = content_type.split(";")[0] + response = await websession.get(url, timeout=_FETCH_TIMEOUT) + if response.status == HTTPStatus.OK: + content = await response.read() + if content_type := response.headers.get(CONTENT_TYPE): + content_type = content_type.split(";")[0] if content is None: url_parts = URL(url) diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py index 6bc4c7a980b..8e7b32cc4b7 100644 --- a/tests/components/demo/test_media_player.py +++ b/tests/components/demo/test_media_player.py @@ -477,7 +477,7 @@ async def test_media_image_proxy( class MockWebsession: """Test websession.""" - async def get(self, url): + async def get(self, url, **kwargs): """Test websession get.""" return MockResponse() From 0425b7aa6d61dfdae0435ea6829a9cce12a3a121 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 29 Apr 2024 08:21:31 +0200 Subject: [PATCH 090/272] Reduce scope of test fixtures for the pylint plugin tests (#116207) --- tests/pylint/conftest.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 2b0fdcf7df5..90e535a7b0e 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -26,7 +26,7 @@ def _load_plugin_from_file(module_name: str, file: str) -> ModuleType: return module -@pytest.fixture(name="hass_enforce_type_hints", scope="session") +@pytest.fixture(name="hass_enforce_type_hints", scope="package") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" return _load_plugin_from_file( @@ -49,7 +49,7 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: return type_hint_checker -@pytest.fixture(name="hass_imports", scope="session") +@pytest.fixture(name="hass_imports", scope="package") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" return _load_plugin_from_file( @@ -66,7 +66,7 @@ def imports_checker_fixture(hass_imports, linter) -> BaseChecker: return type_hint_checker -@pytest.fixture(name="hass_enforce_super_call", scope="session") +@pytest.fixture(name="hass_enforce_super_call", scope="package") def hass_enforce_super_call_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" return _load_plugin_from_file( @@ -83,7 +83,7 @@ def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: return super_call_checker -@pytest.fixture(name="hass_enforce_sorted_platforms", scope="session") +@pytest.fixture(name="hass_enforce_sorted_platforms", scope="package") def hass_enforce_sorted_platforms_fixture() -> ModuleType: """Fixture to the content for the hass_enforce_sorted_platforms check.""" return _load_plugin_from_file( @@ -104,7 +104,7 @@ def enforce_sorted_platforms_checker_fixture( return enforce_sorted_platforms_checker -@pytest.fixture(name="hass_enforce_coordinator_module", scope="session") +@pytest.fixture(name="hass_enforce_coordinator_module", scope="package") def hass_enforce_coordinator_module_fixture() -> ModuleType: """Fixture to the content for the hass_enforce_coordinator_module check.""" return _load_plugin_from_file( From 8153ff78bfd8dd82e460d39fd4e12ef59eed8023 Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Mon, 29 Apr 2024 00:47:05 -0700 Subject: [PATCH 091/272] Add Button for TotalConnect (#114530) * add button for totalconnect * test button for totalconnect * change to zone.can_be_bypassed * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * Update homeassistant/components/totalconnect/button.py Co-authored-by: Jan-Philipp Benecke * remove unused logging * Update homeassistant/components/totalconnect/button.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/totalconnect/button.py Co-authored-by: Joost Lekkerkerker * fix button and test * Revert "bump total_connect_client to 2023.12.1" This reverts commit 189b7dcd89cf3cc8309dacc92ba47927cfbbdef3. * bump total_connect_client to 2023.12.1 * use ZoneEntity for Bypass button * use LocationEntity for PanelButton * fix typing * add translation_key for panel buttons * mock clear_bypass instead of disarm * use paramaterize * use snapshot * sentence case in strings * remove un-needed stuff * Update homeassistant/components/totalconnect/button.py * Apply suggestions from code review * Fix --------- Co-authored-by: Jan-Philipp Benecke Co-authored-by: Joost Lekkerkerker --- .../components/totalconnect/__init__.py | 2 +- .../components/totalconnect/button.py | 101 +++++++ .../components/totalconnect/strings.json | 11 + tests/components/totalconnect/common.py | 19 ++ .../totalconnect/snapshots/test_button.ambr | 277 ++++++++++++++++++ tests/components/totalconnect/test_button.py | 78 +++++ 6 files changed, 487 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/totalconnect/button.py create mode 100644 tests/components/totalconnect/snapshots/test_button.ambr create mode 100644 tests/components/totalconnect/test_button.py diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py index e10858c6c12..76e0a09af39 100644 --- a/homeassistant/components/totalconnect/__init__.py +++ b/homeassistant/components/totalconnect/__init__.py @@ -19,7 +19,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN -PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR] +PLATFORMS = [Platform.ALARM_CONTROL_PANEL, Platform.BINARY_SENSOR, Platform.BUTTON] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) SCAN_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/totalconnect/button.py b/homeassistant/components/totalconnect/button.py new file mode 100644 index 00000000000..ec2d0a604c7 --- /dev/null +++ b/homeassistant/components/totalconnect/button.py @@ -0,0 +1,101 @@ +"""Interfaces with TotalConnect buttons.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from total_connect_client.location import TotalConnectLocation +from total_connect_client.zone import TotalConnectZone + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import TotalConnectDataUpdateCoordinator +from .const import DOMAIN +from .entity import TotalConnectLocationEntity, TotalConnectZoneEntity + + +@dataclass(frozen=True, kw_only=True) +class TotalConnectButtonEntityDescription(ButtonEntityDescription): + """TotalConnect button description.""" + + press_fn: Callable[[TotalConnectLocation], None] + + +PANEL_BUTTONS: tuple[TotalConnectButtonEntityDescription, ...] = ( + TotalConnectButtonEntityDescription( + key="clear_bypass", + translation_key="clear_bypass", + press_fn=lambda location: location.clear_bypass(), + ), + TotalConnectButtonEntityDescription( + key="bypass_all", + translation_key="bypass_all", + press_fn=lambda location: location.zone_bypass_all(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up TotalConnect buttons based on a config entry.""" + buttons: list = [] + coordinator: TotalConnectDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + for location_id, location in coordinator.client.locations.items(): + buttons.extend( + TotalConnectPanelButton(coordinator, location, description) + for description in PANEL_BUTTONS + ) + + buttons.extend( + TotalConnectZoneBypassButton(coordinator, zone, location_id) + for zone in location.zones.values() + if zone.can_be_bypassed + ) + + async_add_entities(buttons) + + +class TotalConnectZoneBypassButton(TotalConnectZoneEntity, ButtonEntity): + """Represent a TotalConnect zone bypass button.""" + + _attr_translation_key = "bypass" + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, + coordinator: TotalConnectDataUpdateCoordinator, + zone: TotalConnectZone, + location_id: str, + ) -> None: + """Initialize the TotalConnect status.""" + super().__init__(coordinator, zone, location_id, "bypass") + + def press(self) -> None: + """Press the bypass button.""" + self._zone.bypass() + + +class TotalConnectPanelButton(TotalConnectLocationEntity, ButtonEntity): + """Generic TotalConnect panel button.""" + + entity_description: TotalConnectButtonEntityDescription + + def __init__( + self, + coordinator: TotalConnectDataUpdateCoordinator, + location: TotalConnectLocation, + entity_description: TotalConnectButtonEntityDescription, + ) -> None: + """Initialize the TotalConnect button.""" + super().__init__(coordinator, location) + self.entity_description = entity_description + self._attr_unique_id = f"{location.location_id}_{entity_description.key}" + + def press(self) -> None: + """Press the button.""" + self.entity_description.press_fn(self._location) diff --git a/homeassistant/components/totalconnect/strings.json b/homeassistant/components/totalconnect/strings.json index 03656b60084..e2e5ed7c490 100644 --- a/homeassistant/components/totalconnect/strings.json +++ b/homeassistant/components/totalconnect/strings.json @@ -55,6 +55,17 @@ "partition": { "name": "Partition {partition_id}" } + }, + "button": { + "clear_bypass": { + "name": "Clear bypass" + }, + "bypass_all": { + "name": "Bypass all" + }, + "bypass": { + "name": "Bypass" + } } } } diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 0dde43a9710..1ceb893112c 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -206,6 +206,17 @@ ZONE_7 = { "CanBeBypassed": 0, } +# ZoneType security that cannot be bypassed is a Button on the alarm panel +ZONE_8 = { + "ZoneID": 8, + "ZoneDescription": "Button", + "ZoneStatus": ZoneStatus.FAULT, + "ZoneTypeId": ZoneType.SECURITY, + "PartitionId": "1", + "CanBeBypassed": 0, +} + + ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6, ZONE_7] ZONES = {"ZoneInfo": ZONE_INFO} @@ -318,6 +329,14 @@ RESPONSE_USER_CODE_INVALID = { "ResultData": "testing user code invalid", } RESPONSE_SUCCESS = {"ResultCode": ResultCode.SUCCESS.value} +RESPONSE_ZONE_BYPASS_SUCCESS = { + "ResultCode": ResultCode.SUCCESS.value, + "ResultData": "None", +} +RESPONSE_ZONE_BYPASS_FAILURE = { + "ResultCode": ResultCode.FAILED_TO_BYPASS_ZONE.value, + "ResultData": "None", +} USERNAME = "username@me.com" PASSWORD = "password" diff --git a/tests/components/totalconnect/snapshots/test_button.ambr b/tests/components/totalconnect/snapshots/test_button.ambr new file mode 100644 index 00000000000..af3318591c6 --- /dev/null +++ b/tests/components/totalconnect/snapshots/test_button.ambr @@ -0,0 +1,277 @@ +# serializer version: 1 +# name: test_entity_registry[button.fire_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.fire_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_2_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.fire_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Fire Bypass', + }), + 'context': , + 'entity_id': 'button.fire_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.gas_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.gas_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_3_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.gas_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Gas Bypass', + }), + 'context': , + 'entity_id': 'button.gas_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.motion_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.motion_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_4_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.motion_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Motion Bypass', + }), + 'context': , + 'entity_id': 'button.motion_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.security_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': , + 'entity_id': 'button.security_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass', + 'unique_id': '123456_1_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.security_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Security Bypass', + }), + 'context': , + 'entity_id': 'button.security_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.test_bypass_all-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_bypass_all', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Bypass all', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'bypass_all', + 'unique_id': '123456_bypass_all', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.test_bypass_all-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test Bypass all', + }), + 'context': , + 'entity_id': 'button.test_bypass_all', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entity_registry[button.test_clear_bypass-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.test_clear_bypass', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Clear bypass', + 'platform': 'totalconnect', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'clear_bypass', + 'unique_id': '123456_clear_bypass', + 'unit_of_measurement': None, + }) +# --- +# name: test_entity_registry[button.test_clear_bypass-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test Clear bypass', + }), + 'context': , + 'entity_id': 'button.test_clear_bypass', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/totalconnect/test_button.py b/tests/components/totalconnect/test_button.py new file mode 100644 index 00000000000..03b08316be2 --- /dev/null +++ b/tests/components/totalconnect/test_button.py @@ -0,0 +1,78 @@ +"""Tests for the TotalConnect buttons.""" + +from unittest.mock import patch + +import pytest +from syrupy import SnapshotAssertion +from total_connect_client.exceptions import FailedToBypassZone + +from homeassistant.components.button import DOMAIN as BUTTON, SERVICE_PRESS +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import ( + RESPONSE_ZONE_BYPASS_FAILURE, + RESPONSE_ZONE_BYPASS_SUCCESS, + TOTALCONNECT_REQUEST, + setup_platform, +) + +from tests.common import snapshot_platform + +ZONE_BYPASS_ID = "button.security_bypass" +PANEL_CLEAR_ID = "button.test_clear_bypass" +PANEL_BYPASS_ID = "button.test_bypass_all" + + +async def test_entity_registry( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: + """Test the button is registered in entity registry.""" + entry = await setup_platform(hass, BUTTON) + + await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id) + + +@pytest.mark.parametrize("entity_id", [ZONE_BYPASS_ID, PANEL_BYPASS_ID]) +async def test_bypass_button(hass: HomeAssistant, entity_id: str) -> None: + """Test pushing a bypass button.""" + responses = [RESPONSE_ZONE_BYPASS_FAILURE, RESPONSE_ZONE_BYPASS_SUCCESS] + await setup_platform(hass, BUTTON) + with patch(TOTALCONNECT_REQUEST, side_effect=responses) as mock_request: + # try to bypass, but fails + with pytest.raises(FailedToBypassZone): + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_request.call_count == 1 + + # try to bypass, works this time + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data={ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert mock_request.call_count == 2 + + +async def test_clear_button(hass: HomeAssistant) -> None: + """Test pushing the clear bypass button.""" + data = {ATTR_ENTITY_ID: PANEL_CLEAR_ID} + await setup_platform(hass, BUTTON) + TOTALCONNECT_REQUEST = ( + "total_connect_client.location.TotalConnectLocation.clear_bypass" + ) + + with patch(TOTALCONNECT_REQUEST) as mock_request: + await hass.services.async_call( + domain=BUTTON, + service=SERVICE_PRESS, + service_data=data, + blocking=True, + ) + assert mock_request.call_count == 1 From 0e0ea0017e28905e7d882c65dfd04d936b94cd6a Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 29 Apr 2024 10:59:36 +0200 Subject: [PATCH 092/272] Add matter during onboarding (#116163) * Add matter during onboarding * test_zeroconf_not_onboarded_running * test_zeroconf_not_onboarded_installed * test_zeroconf_not_onboarded_not_installed * test_zeroconf_discovery_not_onboarded_not_supervisor * Clean up * Add udp address * Test zeroconf udp info too * test_addon_installed_failures_zeroconf * test_addon_running_failures_zeroconf * test_addon_not_installed_failures_zeroconf * Clean up stale changes * Set unique id for discovery step * Fix tests for background flow * Fix flow running in background * Test already discovered zeroconf * Mock unload entry --- .../components/matter/config_flow.py | 27 +- homeassistant/components/matter/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 5 + tests/components/matter/test_config_flow.py | 414 +++++++++++++++++- 4 files changed, 436 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 7dc06807a98..b079dcd9b54 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -17,6 +17,8 @@ from homeassistant.components.hassio import ( HassioServiceInfo, is_hassio, ) +from homeassistant.components.onboarding import async_is_onboarded +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -64,6 +66,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up flow instance.""" + self._running_in_background = False self.ws_address: str | None = None # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False @@ -78,7 +81,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) - if not self.install_task.done(): + if not self._running_in_background and not self.install_task.done(): return self.async_show_progress( step_id="install_addon", progress_action="install_addon", @@ -89,12 +92,16 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.install_task except AddonError as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_install_failed() return self.async_show_progress_done(next_step_id="install_failed") finally: self.install_task = None self.integration_created_addon = True + if self._running_in_background: + return await self.async_step_start_addon() return self.async_show_progress_done(next_step_id="start_addon") async def async_step_install_failed( @@ -125,7 +132,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): """Start Matter Server add-on.""" if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) - if not self.start_task.done(): + if not self._running_in_background and not self.start_task.done(): return self.async_show_progress( step_id="start_addon", progress_action="start_addon", @@ -136,10 +143,14 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.start_task except (FailedConnect, AddonError, AbortFlow) as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_start_failed() return self.async_show_progress_done(next_step_id="start_failed") finally: self.start_task = None + if self._running_in_background: + return await self.async_step_finish_addon_setup() return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -223,6 +234,18 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): step_id="manual", data_schema=get_manual_schema(user_input), errors=errors ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + if not async_is_onboarded(self.hass) and is_hassio(self.hass): + await self._async_handle_discovery_without_unique_id() + self._running_in_background = True + return await self.async_step_on_supervisor( + user_input={CONF_USE_ADDON: True} + ) + return await self._async_step_discovery_without_unique_id() + async def async_step_hassio( self, discovery_info: HassioServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 0e27eb36f85..b3acc0d547c 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", "requirements": ["python-matter-server==5.7.0"], - "zeroconf": ["_matter._tcp.local."] + "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3441026994b..7b1bbff9de0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -608,6 +608,11 @@ ZEROCONF = { "domain": "matter", }, ], + "_matterc._udp.local.": [ + { + "domain": "matter", + }, + ], "_mediaremotetv._tcp.local.": [ { "domain": "apple_tv", diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index 33e94a743f7..39ae40172c1 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -24,6 +24,37 @@ ADDON_DISCOVERY_INFO = { "host": "host1", "port": 5581, } +ZEROCONF_INFO_TCP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matter._tcp.local.", + name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", + properties={"SII": "3300", "SAI": "1100", "T": "0"}, +) + +ZEROCONF_INFO_UDP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matterc._udp.local.", + name="ABCDEFGH123456789._matterc._udp.local.", + properties={ + "VP": "4874+77", + "DT": "21", + "DN": "Eve Door", + "SII": "3300", + "SAI": "1100", + "T": "0", + "D": "183", + "CM": "2", + "RI": "0400530980B950D59BF473CFE42BD7DDBF2D", + "PH": "36", + "PI": None, + }, +) @pytest.fixture(name="setup_entry", autouse=True) @@ -35,6 +66,15 @@ def setup_entry_fixture() -> Generator[AsyncMock, None, None]: yield mock_setup_entry +@pytest.fixture(name="unload_entry", autouse=True) +def unload_entry_fixture() -> Generator[AsyncMock, None, None]: + """Mock entry unload.""" + with patch( + "homeassistant.components.matter.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="client_connect", autouse=True) def client_connect_fixture() -> Generator[AsyncMock, None, None]: """Mock server version.""" @@ -80,6 +120,16 @@ def addon_setup_time_fixture() -> Generator[int, None, None]: yield addon_setup_time +@pytest.fixture(name="not_onboarded") +def mock_onboarded_fixture() -> Generator[MagicMock, None, None]: + """Mock that Home Assistant is not yet onboarded.""" + with patch( + "homeassistant.components.matter.config_flow.async_is_onboarded", + return_value=False, + ) as mock_onboarded: + yield mock_onboarded + + async def test_manual_create_entry( hass: HomeAssistant, client_connect: AsyncMock, @@ -179,24 +229,18 @@ async def test_manual_already_configured( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) async def test_zeroconf_discovery( hass: HomeAssistant, client_connect: AsyncMock, setup_entry: AsyncMock, + zeroconf_info: ZeroconfServiceInfo, ) -> None: """Test flow started from Zeroconf discovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), - ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], - port=5540, - hostname="CDEFGHIJ12345678.local.", - type="_matter._tcp.local.", - name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", - properties={"SII": "3300", "SAI": "1100", "T": "0"}, - ), + data=zeroconf_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" @@ -221,6 +265,185 @@ async def test_zeroconf_discovery( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_zeroconf_discovery_not_onboarded_not_supervisor( + hass: HomeAssistant, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow started from Zeroconf discovery when not onboarded.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:5580/ws", + }, + ) + await hass.async_block_till_done() + + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://localhost:5580/ws", + "integration_created_addon": False, + "use_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_already_discovered( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and already discovered.""" + result_flow_1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + result_flow_2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + assert result_flow_2["type"] is FlowResultType.ABORT + assert result_flow_2["reason"] == "already_configured" + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result_flow_1["type"] is FlowResultType.CREATE_ENTRY + assert result_flow_1["title"] == "Matter" + assert result_flow_1["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_running( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_installed: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_not_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_store_info: AsyncMock, + addon_not_installed: AsyncMock, + install_addon: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on not installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 0 + assert addon_store_info.call_count == 2 + assert install_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": True, + } + assert setup_entry.call_count == 1 + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_supervisor_discovery( hass: HomeAssistant, @@ -702,6 +925,90 @@ async def test_addon_running_failures( assert result["reason"] == abort_reason +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "discovery_info_error", + "client_connect_error", + "addon_info_error", + "abort_reason", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + None, + "cannot_connect", + True, + True, + ), + ( + None, + None, + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + None, + HassioAPIError(), + "addon_info_failed", + False, + False, + ), + ], +) +async def test_addon_running_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_running: AsyncMock, + addon_info: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + discovery_info_error: Exception | None, + client_connect_error: Exception | None, + addon_info_error: Exception | None, + abort_reason: str, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test all failures when add-on is running and not onboarded.""" + get_addon_discovery_info.side_effect = discovery_info_error + client_connect.side_effect = client_connect_error + addon_info.side_effect = addon_info_error + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running_already_configured( hass: HomeAssistant, @@ -854,6 +1161,71 @@ async def test_addon_installed_failures( assert result["reason"] == "addon_start_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "start_addon_error", + "client_connect_error", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + False, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + True, + True, + ), + ( + None, + None, + None, + True, + False, + ), + ], +) +async def test_addon_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_installed: AsyncMock, + addon_info: AsyncMock, + start_addon: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + start_addon_error: Exception | None, + client_connect_error: Exception | None, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on start failure when add-on is installed and not onboarded.""" + start_addon.side_effect = start_addon_error + client_connect.side_effect = client_connect_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_installed_already_configured( hass: HomeAssistant, @@ -985,6 +1357,30 @@ async def test_addon_not_installed_failures( assert result["reason"] == "addon_install_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_addon_not_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_not_installed: AsyncMock, + addon_info: AsyncMock, + install_addon: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on install failure.""" + install_addon.side_effect = HassioAPIError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert install_addon.call_args == call(hass, "core_matter_server") + assert addon_info.call_count == 0 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_install_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_not_installed_already_configured( hass: HomeAssistant, From b426c4133d4c516b1136d8493532e91936b1df23 Mon Sep 17 00:00:00 2001 From: Marco van 't Wout Date: Mon, 29 Apr 2024 12:02:49 +0200 Subject: [PATCH 093/272] Improve error handling for HTTP errors on Growatt Server (#110633) * Update dependency growattServer for improved error details Updating to latest version. Since version 1.3.1 it will raise requests.exceptions.HTTPError for unexpected API responses such as HTTP 405 (rate limiting/firewall) * Improve error details by raising ConfigEntryAuthFailed Previous code was returning None which the caller couldn't handle * Use a more appropiate exception type * Update homeassistant/components/growatt_server/sensor.py * Update homeassistant/components/growatt_server/sensor.py * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/growatt_server/manifest.json | 2 +- homeassistant/components/growatt_server/sensor.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index d872474f1da..98ceb35ee17 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.3.0"] + "requirements": ["growattServer==1.5.0"] } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 3cf1fa30c99..c41d3ac486f 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle, dt as dt_util @@ -46,8 +47,7 @@ def get_device_list(api, config): not login_response["success"] and login_response["msg"] == LOGIN_INVALID_AUTH_CODE ): - _LOGGER.error("Username, Password or URL may be incorrect!") - return + raise ConfigEntryError("Username, Password or URL may be incorrect!") user_id = login_response["user"]["id"] if plant_id == DEFAULT_PLANT_ID: plant_info = api.plant_list(user_id) diff --git a/requirements_all.txt b/requirements_all.txt index d4ce7689f42..551d6ae4755 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ greenwavereality==0.5.1 gridnet==5.0.0 # homeassistant.components.growatt_server -growattServer==1.3.0 +growattServer==1.5.0 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb9cfa33151..5faa35b01dc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.0 # homeassistant.components.growatt_server -growattServer==1.3.0 +growattServer==1.5.0 # homeassistant.components.google_sheets gspread==5.5.0 From 0b8838cab8d61e52733699f8d0b8835037d838ac Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Mon, 29 Apr 2024 12:51:38 +0200 Subject: [PATCH 094/272] Add icons and translations to Habitica (#116204) * refactor habitica sensors, add strings and icon translations * Change sensor names * remove max_health as it is a fixed value * remove SENSOR_TYPES * removed wrong sensor * Move Data coordinator to separate module * add coordinator.py to coveragerc * add deprecation warning for task sensors * remove unused imports and logger * Revert "add deprecation warning for task sensors" This reverts commit 9e58053f3bb8b34b8e22d525bfd1ff55610f4581. * Update homeassistant/components/habitica/strings.json Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/habitica/strings.json Co-authored-by: Joost Lekkerkerker * Revert "Move Data coordinator to separate module" This reverts commit f5c8c3c886a868b2ed50ad2098fe3cb1ccc01c62. * Revert "add coordinator.py to coveragerc" This reverts commit 8ae07a4786db786a73fc527e525813147d1c5ec4. * rename Mana max. to Max. mana * deprecation for yaml import * move SensorType definition before TASK_TYPES * Revert "deprecation for yaml import" This reverts commit 2a1d58ee5ff7d4f1a19b7593cb7f56afde4e1d9d. --------- Co-authored-by: Joost Lekkerkerker --- CODEOWNERS | 4 +- homeassistant/components/habitica/__init__.py | 3 +- homeassistant/components/habitica/const.py | 3 + homeassistant/components/habitica/icons.json | 46 +++++ .../components/habitica/manifest.json | 2 +- homeassistant/components/habitica/sensor.py | 193 +++++++++++++----- .../components/habitica/strings.json | 40 ++++ 7 files changed, 236 insertions(+), 55 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f954675f4d4..fdea411d208 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -550,8 +550,8 @@ build.json @home-assistant/supervisor /tests/components/group/ @home-assistant/core /homeassistant/components/guardian/ @bachya /tests/components/guardian/ @bachya -/homeassistant/components/habitica/ @ASMfreaK @leikoilja -/tests/components/habitica/ @ASMfreaK @leikoilja +/homeassistant/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r +/tests/components/habitica/ @ASMfreaK @leikoilja @tr4nt0r /homeassistant/components/hardkernel/ @home-assistant/core /tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index f05bc9c1713..34736116a26 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -30,10 +30,11 @@ from .const import ( EVENT_API_CALL_SUCCESS, SERVICE_API_CALL, ) -from .sensor import SENSORS_TYPES _LOGGER = logging.getLogger(__name__) +SENSORS_TYPES = ["name", "hp", "maxHealth", "mp", "maxMP", "exp", "toNextLevel", "lvl"] + INSTANCE_SCHEMA = vol.All( cv.deprecated(CONF_SENSORS), vol.Schema( diff --git a/homeassistant/components/habitica/const.py b/homeassistant/components/habitica/const.py index 1379f0a6447..13babdf458a 100644 --- a/homeassistant/components/habitica/const.py +++ b/homeassistant/components/habitica/const.py @@ -15,3 +15,6 @@ ATTR_ARGS = "args" # event constants EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" ATTR_DATA = "data" + +MANUFACTURER = "HabitRPG, Inc." +NAME = "Habitica" diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 4e5831c4e82..5a722ce6f4b 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -1,4 +1,50 @@ { + "entity": { + "sensor": { + "display_name": { + "default": "mdi:account-circle" + }, + "health": { + "default": "mdi:heart", + "state": { + "0": "mdi:skull-outline" + } + }, + "health_max": { + "default": "mdi:heart" + }, + "mana": { + "default": "mdi:flask", + "state": { + "0": "mdi:flask-empty-outline" + } + }, + "mana_max": { + "default": "mdi:flask" + }, + "experience": { + "default": "mdi:star-four-points" + }, + "experience_max": { + "default": "mdi:star-four-points" + }, + "level": { + "default": "mdi:crown-circle" + }, + "gold": { + "default": "mdi:sack" + }, + "class": { + "default": "mdi:sword", + "state": { + "warrior": "mdi:sword", + "healer": "mdi:shield", + "wizard": "mdi:wizard-hat", + "rogue": "mdi:ninja" + } + } + } + }, "services": { "api_call": "mdi:console" } diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json index f5f746c979d..1250e6d223f 100644 --- a/homeassistant/components/habitica/manifest.json +++ b/homeassistant/components/habitica/manifest.json @@ -1,7 +1,7 @@ { "domain": "habitica", "name": "Habitica", - "codeowners": ["@ASMfreaK", "@leikoilja"], + "codeowners": ["@ASMfreaK", "@leikoilja", "@tr4nt0r"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/habitica", "iot_class": "cloud_polling", diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 4d48ec199ec..7ced7cbf192 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -3,42 +3,123 @@ from __future__ import annotations from collections import namedtuple +from dataclasses import dataclass from datetime import timedelta +from enum import StrEnum from http import HTTPStatus import logging +from typing import TYPE_CHECKING, Any from aiohttp import ClientResponseError -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_URL from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle -from .const import DOMAIN +from .const import DOMAIN, MANUFACTURER, NAME _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) -SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) -SENSORS_TYPES = { - "name": SensorType("Name", None, None, ["profile", "name"]), - "hp": SensorType("HP", "mdi:heart", "HP", ["stats", "hp"]), - "maxHealth": SensorType("max HP", "mdi:heart", "HP", ["stats", "maxHealth"]), - "mp": SensorType("Mana", "mdi:auto-fix", "MP", ["stats", "mp"]), - "maxMP": SensorType("max Mana", "mdi:auto-fix", "MP", ["stats", "maxMP"]), - "exp": SensorType("EXP", "mdi:star", "EXP", ["stats", "exp"]), - "toNextLevel": SensorType("Next Lvl", "mdi:star", "EXP", ["stats", "toNextLevel"]), - "lvl": SensorType( - "Lvl", "mdi:arrow-up-bold-circle-outline", "Lvl", ["stats", "lvl"] +@dataclass(kw_only=True, frozen=True) +class HabitipySensorEntityDescription(SensorEntityDescription): + """Habitipy Sensor Description.""" + + value_path: list[str] + + +class HabitipySensorEntity(StrEnum): + """Habitipy Entities.""" + + DISPLAY_NAME = "display_name" + HEALTH = "health" + HEALTH_MAX = "health_max" + MANA = "mana" + MANA_MAX = "mana_max" + EXPERIENCE = "experience" + EXPERIENCE_MAX = "experience_max" + LEVEL = "level" + GOLD = "gold" + CLASS = "class" + + +SENSOR_DESCRIPTIONS: dict[str, HabitipySensorEntityDescription] = { + HabitipySensorEntity.DISPLAY_NAME: HabitipySensorEntityDescription( + key=HabitipySensorEntity.DISPLAY_NAME, + translation_key=HabitipySensorEntity.DISPLAY_NAME, + value_path=["profile", "name"], + ), + HabitipySensorEntity.HEALTH: HabitipySensorEntityDescription( + key=HabitipySensorEntity.HEALTH, + translation_key=HabitipySensorEntity.HEALTH, + native_unit_of_measurement="HP", + suggested_display_precision=0, + value_path=["stats", "hp"], + ), + HabitipySensorEntity.HEALTH_MAX: HabitipySensorEntityDescription( + key=HabitipySensorEntity.HEALTH_MAX, + translation_key=HabitipySensorEntity.HEALTH_MAX, + native_unit_of_measurement="HP", + entity_registry_enabled_default=False, + value_path=["stats", "maxHealth"], + ), + HabitipySensorEntity.MANA: HabitipySensorEntityDescription( + key=HabitipySensorEntity.MANA, + translation_key=HabitipySensorEntity.MANA, + native_unit_of_measurement="MP", + suggested_display_precision=0, + value_path=["stats", "mp"], + ), + HabitipySensorEntity.MANA_MAX: HabitipySensorEntityDescription( + key=HabitipySensorEntity.MANA_MAX, + translation_key=HabitipySensorEntity.MANA_MAX, + native_unit_of_measurement="MP", + value_path=["stats", "maxMP"], + ), + HabitipySensorEntity.EXPERIENCE: HabitipySensorEntityDescription( + key=HabitipySensorEntity.EXPERIENCE, + translation_key=HabitipySensorEntity.EXPERIENCE, + native_unit_of_measurement="XP", + value_path=["stats", "exp"], + ), + HabitipySensorEntity.EXPERIENCE_MAX: HabitipySensorEntityDescription( + key=HabitipySensorEntity.EXPERIENCE_MAX, + translation_key=HabitipySensorEntity.EXPERIENCE_MAX, + native_unit_of_measurement="XP", + value_path=["stats", "toNextLevel"], + ), + HabitipySensorEntity.LEVEL: HabitipySensorEntityDescription( + key=HabitipySensorEntity.LEVEL, + translation_key=HabitipySensorEntity.LEVEL, + value_path=["stats", "lvl"], + ), + HabitipySensorEntity.GOLD: HabitipySensorEntityDescription( + key=HabitipySensorEntity.GOLD, + translation_key=HabitipySensorEntity.GOLD, + native_unit_of_measurement="GP", + suggested_display_precision=2, + value_path=["stats", "gp"], + ), + HabitipySensorEntity.CLASS: HabitipySensorEntityDescription( + key=HabitipySensorEntity.CLASS, + translation_key=HabitipySensorEntity.CLASS, + value_path=["stats", "class"], + device_class=SensorDeviceClass.ENUM, + options=["warrior", "healer", "wizard", "rogue"], ), - "gp": SensorType("Gold", "mdi:circle-multiple", "Gold", ["stats", "gp"]), - "class": SensorType("Class", "mdi:sword", None, ["stats", "class"]), } +SensorType = namedtuple("SensorType", ["name", "icon", "unit", "path"]) TASKS_TYPES = { "habits": SensorType( "Habits", "mdi:clipboard-list-outline", "n_of_tasks", ["habits"] @@ -92,10 +173,12 @@ async def async_setup_entry( await sensor_data.update() entities: list[SensorEntity] = [ - HabitipySensor(name, sensor_type, sensor_data) for sensor_type in SENSORS_TYPES + HabitipySensor(sensor_data, description, config_entry) + for description in SENSOR_DESCRIPTIONS.values() ] entities.extend( - HabitipyTaskSensor(name, task_type, sensor_data) for task_type in TASKS_TYPES + HabitipyTaskSensor(name, task_type, sensor_data, config_entry) + for task_type in TASKS_TYPES ) async_add_entities(entities, True) @@ -103,7 +186,9 @@ async def async_setup_entry( class HabitipyData: """Habitica API user data cache.""" - def __init__(self, api): + tasks: dict[str, Any] + + def __init__(self, api) -> None: """Habitica API user data cache.""" self.api = api self.data = None @@ -153,53 +238,59 @@ class HabitipyData: class HabitipySensor(SensorEntity): """A generic Habitica sensor.""" - def __init__(self, name, sensor_name, updater): + _attr_has_entity_name = True + entity_description: HabitipySensorEntityDescription + + def __init__( + self, + coordinator, + entity_description: HabitipySensorEntityDescription, + entry: ConfigEntry, + ) -> None: """Initialize a generic Habitica sensor.""" - self._name = name - self._sensor_name = sensor_name - self._sensor_type = SENSORS_TYPES[sensor_name] - self._state = None - self._updater = updater + super().__init__() + if TYPE_CHECKING: + assert entry.unique_id + self.coordinator = coordinator + self.entity_description = entity_description + self._attr_unique_id = f"{entry.unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=entry.data[CONF_NAME], + configuration_url=entry.data[CONF_URL], + identifiers={(DOMAIN, entry.unique_id)}, + ) async def async_update(self) -> None: - """Update Condition and Forecast.""" - await self._updater.update() - data = self._updater.data - for element in self._sensor_type.path: + """Update Sensor state.""" + await self.coordinator.update() + data = self.coordinator.data + for element in self.entity_description.value_path: data = data[element] - self._state = data - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._sensor_type.icon - - @property - def name(self): - """Return the name of the sensor.""" - return f"{DOMAIN}_{self._name}_{self._sensor_name}" - - @property - def native_value(self): - """Return the state of the device.""" - return self._state - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._sensor_type.unit + self._attr_native_value = data class HabitipyTaskSensor(SensorEntity): """A Habitica task sensor.""" - def __init__(self, name, task_name, updater): + def __init__(self, name, task_name, updater, entry): """Initialize a generic Habitica task.""" self._name = name self._task_name = task_name self._task_type = TASKS_TYPES[task_name] self._state = None self._updater = updater + self._attr_unique_id = f"{entry.unique_id}_{task_name}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + manufacturer=MANUFACTURER, + model=NAME, + name=entry.data[CONF_NAME], + configuration_url=entry.data[CONF_URL], + identifiers={(DOMAIN, entry.unique_id)}, + ) async def async_update(self) -> None: """Update Condition and Forecast.""" diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 8dacb0e6321..6be2bd7ed09 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -19,6 +19,46 @@ } } }, + "entity": { + "sensor": { + "display_name": { + "name": "Display name" + }, + "health": { + "name": "Health" + }, + "health_max": { + "name": "Max. health" + }, + "mana": { + "name": "Mana" + }, + "mana_max": { + "name": "Max. mana" + }, + "experience": { + "name": "Experience" + }, + "experience_max": { + "name": "Next level" + }, + "level": { + "name": "Level" + }, + "gold": { + "name": "Gold" + }, + "class": { + "name": "Class", + "state": { + "warrior": "Warrior", + "healer": "Healer", + "wizard": "Mage", + "rogue": "Rogue" + } + } + } + }, "services": { "api_call": { "name": "API name", From fd52348d5730479ead8ad8897f0bcac62343d456 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:40:47 +0200 Subject: [PATCH 095/272] Update freezegun to 1.5.0 (#116375) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7fa9b3d8c89..1ae47b0d636 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -9,7 +9,7 @@ -r requirements_test_pre_commit.txt astroid==3.1.0 coverage==7.5.0 -freezegun==1.4.0 +freezegun==1.5.0 mock-open==1.4.0 mypy==1.10.0 pre-commit==3.7.0 From 26fad0b78649d5878f7b8499a124279cd3500b02 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:42:57 +0200 Subject: [PATCH 096/272] Update pytest-xdist to 3.6.1 (#116377) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 1ae47b0d636..69b47c02bbf 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -27,7 +27,7 @@ pytest-sugar==1.0.0 pytest-timeout==2.3.1 pytest-unordered==0.6.0 pytest-picked==0.5.0 -pytest-xdist==3.5.0 +pytest-xdist==3.6.1 pytest==8.1.1 requests-mock==1.12.1 respx==0.21.0 From e060e908587beae94070633d33d2d9d0c2793b69 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:44:04 +0200 Subject: [PATCH 097/272] Update pipdeptree to 2.19.0 (#116376) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 69b47c02bbf..ad97255cd0e 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,7 +16,7 @@ pre-commit==3.7.0 pydantic==1.10.12 pylint==3.1.0 pylint-per-file-ignores==1.3.2 -pipdeptree==2.17.0 +pipdeptree==2.19.0 pytest-asyncio==0.23.6 pytest-aiohttp==1.0.5 pytest-cov==5.0.0 From de65e6b5d18d987cbb67e4233a0ebb7af191355b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:45:57 +0200 Subject: [PATCH 098/272] Update respx to 0.21.1 (#116380) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ad97255cd0e..35d21c04738 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -30,7 +30,7 @@ pytest-picked==0.5.0 pytest-xdist==3.6.1 pytest==8.1.1 requests-mock==1.12.1 -respx==0.21.0 +respx==0.21.1 syrupy==4.6.1 tqdm==4.66.2 types-aiofiles==23.2.0.20240311 From 6c9f277bbeac7beb969db338d594e976622c9197 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:48:13 +0200 Subject: [PATCH 099/272] Update uv to 0.1.39 (#116381) --- Dockerfile | 2 +- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c916a3d2f3c..93865bc21f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ ENV \ ARG QEMU_CPU # Install uv -RUN pip3 install uv==0.1.35 +RUN pip3 install uv==0.1.39 WORKDIR /usr/src diff --git a/requirements_test.txt b/requirements_test.txt index 35d21c04738..e2429aa9217 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -50,4 +50,4 @@ types-pytz==2024.1.0.20240203 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 -uv==0.1.35 +uv==0.1.39 From 8ac493fcf41cd3789bc6fd7cefa8ecb40c542693 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 29 Apr 2024 14:50:00 +0200 Subject: [PATCH 100/272] Update types packages (#116382) --- requirements_test.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index e2429aa9217..96263c64712 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,20 +33,20 @@ requests-mock==1.12.1 respx==0.21.1 syrupy==4.6.1 tqdm==4.66.2 -types-aiofiles==23.2.0.20240311 +types-aiofiles==23.2.0.20240403 types-atomicwrites==1.4.5.1 -types-croniter==2.0.0.20240321 +types-croniter==2.0.0.20240423 types-beautifulsoup4==4.12.0.20240229 -types-caldav==1.3.0.20240106 +types-caldav==1.3.0.20240331 types-chardet==0.1.5 types-decorator==5.1.8.20240310 types-paho-mqtt==1.6.0.20240321 -types-pillow==10.2.0.20240324 +types-pillow==10.2.0.20240423 types-protobuf==4.24.0.20240106 -types-psutil==5.9.5.20240316 +types-psutil==5.9.5.20240423 types-python-dateutil==2.9.0.20240316 types-python-slugify==8.0.2.20240310 -types-pytz==2024.1.0.20240203 +types-pytz==2024.1.0.20240417 types-PyYAML==6.0.12.20240311 types-requests==2.31.0.3 types-xmltodict==0.13.0.3 From d1f88ffd1e204c6c5d9d504119901a2ef33aa52b Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 29 Apr 2024 16:03:57 +0300 Subject: [PATCH 101/272] Prevent Shelly raising in a task (#116355) Co-authored-by: J. Nick Koston --- .../components/shelly/coordinator.py | 24 +++-- tests/components/shelly/test_coordinator.py | 96 ++++++++++++++++++- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d3d7b86de11..e321f393ba3 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -154,24 +154,27 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): ) self.device_id = device_entry.id - async def _async_device_connect(self) -> None: - """Connect to a Shelly Block device.""" + async def _async_device_connect_task(self) -> bool: + """Connect to a Shelly device task.""" LOGGER.debug("Connecting to Shelly Device - %s", self.name) try: await self.device.initialize() update_device_fw_info(self.hass, self.device, self.entry) except DeviceConnectionError as err: - raise UpdateFailed(f"Device disconnected: {repr(err)}") from err + LOGGER.debug( + "Error connecting to Shelly device %s, error: %r", self.name, err + ) + return False except InvalidAuthError: self.entry.async_start_reauth(self.hass) - return + return False if not self.device.firmware_supported: async_create_issue_unsupported_firmware(self.hass, self.entry) - return + return False if not self._pending_platforms: - return + return True LOGGER.debug("Device %s is online, resuming setup", self.entry.title) platforms = self._pending_platforms @@ -193,6 +196,8 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): # Resume platform setup await self.hass.config_entries.async_forward_entry_setups(self.entry, platforms) + return True + async def _async_reload_entry(self) -> None: """Reload entry.""" self._debounced_reload.async_cancel() @@ -363,7 +368,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if update_type is BlockUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, - self._async_device_connect(), + self._async_device_connect_task(), "block device online", eager_start=True, ) @@ -591,7 +596,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.device.connected: return - await self._async_device_connect() + if not await self._async_device_connect_task(): + raise UpdateFailed("Device reconnect error") async def _async_disconnected(self) -> None: """Handle device disconnected.""" @@ -661,7 +667,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if update_type is RpcUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, - self._async_device_connect(), + self._async_device_connect_task(), "rpc device online", eager_start=True, ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 1e581e156c5..1dc45a98c44 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -24,10 +24,11 @@ from homeassistant.components.shelly.const import ( ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + DeviceRegistry, async_entries_for_config_entry, async_get as async_get_dev_reg, format_mac, @@ -40,10 +41,11 @@ from . import ( inject_rpc_device_event, mock_polling_rpc_update, mock_rest_update, + register_device, register_entity, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache RELAY_BLOCK_ID = 0 LIGHT_BLOCK_ID = 2 @@ -806,3 +808,93 @@ async def test_rpc_runs_connected_events_when_initialized( # BLE script list is called during connected events assert call.script_list() in mock_rpc_device.mock_calls + + +async def test_block_sleeping_device_connection_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test block sleeping device connection error during initialize.""" + sleep_period = 1000 + entry = await init_integration(hass, 1, sleep_period=sleep_period, skip_setup=True) + register_device(device_reg, entry) + entity_id = register_entity( + hass, BINARY_SENSOR_DOMAIN, "test_name_motion", "sensor_0-motion", entry + ) + mock_restore_cache(hass, [State(entity_id, STATE_ON)]) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_ON + + # Make device online event with connection error + monkeypatch.setattr( + mock_block_device, + "initialize", + AsyncMock( + side_effect=DeviceConnectionError, + ), + ) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error connecting to Shelly device" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_ON + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=sleep_period * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Sleeping device did not update" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + + +async def test_rpc_sleeping_device_connection_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test RPC sleeping device connection error during initialize.""" + sleep_period = 1000 + entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) + register_device(device_reg, entry) + entity_id = register_entity( + hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud-cloud", entry + ) + mock_restore_cache(hass, [State(entity_id, STATE_ON)]) + monkeypatch.setattr(mock_rpc_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_ON + + # Make device online event with connection error + monkeypatch.setattr( + mock_rpc_device, + "initialize", + AsyncMock( + side_effect=DeviceConnectionError, + ), + ) + mock_rpc_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error connecting to Shelly device" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_ON + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=sleep_period * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Sleeping device did not update" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE From 81d2f5b791b1a5965b10b6d362d3b98230d3b68d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 08:43:55 -0500 Subject: [PATCH 102/272] Small cleanups to climate entity feature compat (#116361) * Small cleanups to climate entity feature compat Fix some duplicate property fetches, avoid generating a new enum every time supported_features was fetched if there was no modifier * param * param --- homeassistant/components/climate/__init__.py | 27 +++++++++++++------- tests/components/climate/test_init.py | 19 +++++++++++--- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index bda00c9b57f..9084a138350 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -325,16 +325,24 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Convert the supported features to ClimateEntityFeature. # Remove this compatibility shim in 2025.1 or later. - _supported_features = super().__getattribute__(__name) + _supported_features: ClimateEntityFeature = super().__getattribute__( + "supported_features" + ) + _mod_supported_features: ClimateEntityFeature = super().__getattribute__( + "_ClimateEntity__mod_supported_features" + ) if type(_supported_features) is int: # noqa: E721 - new_features = ClimateEntityFeature(_supported_features) - self._report_deprecated_supported_features_values(new_features) + _features = ClimateEntityFeature(_supported_features) + self._report_deprecated_supported_features_values(_features) + else: + _features = _supported_features + + if not _mod_supported_features: + return _features # Add automatically calculated ClimateEntityFeature.TURN_OFF/TURN_ON to # supported features and return it - return _supported_features | super().__getattribute__( - "_ClimateEntity__mod_supported_features" - ) + return _features | _mod_supported_features @callback def add_to_platform_start( @@ -375,7 +383,8 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): # Return if integration has migrated already return - if not self.supported_features & ClimateEntityFeature.TURN_OFF and ( + supported_features = self.supported_features + if not supported_features & ClimateEntityFeature.TURN_OFF and ( type(self).async_turn_off is not ClimateEntity.async_turn_off or type(self).turn_off is not ClimateEntity.turn_off ): @@ -385,7 +394,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): ClimateEntityFeature.TURN_OFF ) - if not self.supported_features & ClimateEntityFeature.TURN_ON and ( + if not supported_features & ClimateEntityFeature.TURN_ON and ( type(self).async_turn_on is not ClimateEntity.async_turn_on or type(self).turn_on is not ClimateEntity.turn_on ): @@ -398,7 +407,7 @@ class ClimateEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): if (modes := self.hvac_modes) and len(modes) >= 2 and HVACMode.OFF in modes: # turn_on/off implicitly supported by including more modes than 1 and one of these # are HVACMode.OFF - _modes = [_mode for _mode in self.hvac_modes if _mode is not None] + _modes = [_mode for _mode in modes if _mode is not None] _report_turn_on_off(", ".join(_modes or []), "turn_on/turn_off") self.__mod_supported_features |= ( # pylint: disable=unused-private-member ClimateEntityFeature.TURN_ON | ClimateEntityFeature.TURN_OFF diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py index ed942fb1464..0d6927ae0f9 100644 --- a/tests/components/climate/test_init.py +++ b/tests/components/climate/test_init.py @@ -358,23 +358,34 @@ async def test_preset_mode_validation( assert exc.value.translation_key == "not_valid_fan_mode" -def test_deprecated_supported_features_ints(caplog: pytest.LogCaptureFixture) -> None: +@pytest.mark.parametrize( + "supported_features_at_int", + [ + ClimateEntityFeature.TARGET_TEMPERATURE.value, + ClimateEntityFeature.TARGET_TEMPERATURE.value + | ClimateEntityFeature.TURN_ON.value + | ClimateEntityFeature.TURN_OFF.value, + ], +) +def test_deprecated_supported_features_ints( + caplog: pytest.LogCaptureFixture, supported_features_at_int: int +) -> None: """Test deprecated supported features ints.""" class MockClimateEntity(ClimateEntity): @property def supported_features(self) -> int: """Return supported features.""" - return 1 + return supported_features_at_int entity = MockClimateEntity() - assert entity.supported_features is ClimateEntityFeature(1) + assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) assert "MockClimateEntity" in caplog.text assert "is using deprecated supported features values" in caplog.text assert "Instead it should use" in caplog.text assert "ClimateEntityFeature.TARGET_TEMPERATURE" in caplog.text caplog.clear() - assert entity.supported_features is ClimateEntityFeature(1) + assert entity.supported_features is ClimateEntityFeature(supported_features_at_int) assert "is using deprecated supported features values" not in caplog.text From eced3b0f570f22a5508bcb47ee9ba0391b839632 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 09:07:48 -0500 Subject: [PATCH 103/272] Fix usb scan delaying shutdown (#116390) If the integration page is accessed right before shutdown it can trigger the usb scan debouncer which was not marked as background so shutdown would wait for the scan to finish --- homeassistant/components/usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 959a8f5894c..46950ba5b91 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -394,6 +394,7 @@ class USBDiscovery: cooldown=REQUEST_SCAN_COOLDOWN, immediate=True, function=self._async_scan, + background=True, ) await self._request_debouncer.async_call() From ee4f55a5a94fafda384cad595bbd88728d6f5218 Mon Sep 17 00:00:00 2001 From: Marco van 't Wout Date: Mon, 29 Apr 2024 12:02:49 +0200 Subject: [PATCH 104/272] Improve error handling for HTTP errors on Growatt Server (#110633) * Update dependency growattServer for improved error details Updating to latest version. Since version 1.3.1 it will raise requests.exceptions.HTTPError for unexpected API responses such as HTTP 405 (rate limiting/firewall) * Improve error details by raising ConfigEntryAuthFailed Previous code was returning None which the caller couldn't handle * Use a more appropiate exception type * Update homeassistant/components/growatt_server/sensor.py * Update homeassistant/components/growatt_server/sensor.py * Fix --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/growatt_server/manifest.json | 2 +- homeassistant/components/growatt_server/sensor.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index d872474f1da..98ceb35ee17 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/growatt_server", "iot_class": "cloud_polling", "loggers": ["growattServer"], - "requirements": ["growattServer==1.3.0"] + "requirements": ["growattServer==1.5.0"] } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index 3cf1fa30c99..c41d3ac486f 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle, dt as dt_util @@ -46,8 +47,7 @@ def get_device_list(api, config): not login_response["success"] and login_response["msg"] == LOGIN_INVALID_AUTH_CODE ): - _LOGGER.error("Username, Password or URL may be incorrect!") - return + raise ConfigEntryError("Username, Password or URL may be incorrect!") user_id = login_response["user"]["id"] if plant_id == DEFAULT_PLANT_ID: plant_info = api.plant_list(user_id) diff --git a/requirements_all.txt b/requirements_all.txt index d78a00ca68e..205907c1288 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ greenwavereality==0.5.1 gridnet==5.0.0 # homeassistant.components.growatt_server -growattServer==1.3.0 +growattServer==1.5.0 # homeassistant.components.google_sheets gspread==5.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ed5ec38af1d..5e5aecc63e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -821,7 +821,7 @@ greeneye_monitor==3.0.3 gridnet==5.0.0 # homeassistant.components.growatt_server -growattServer==1.3.0 +growattServer==1.5.0 # homeassistant.components.google_sheets gspread==5.5.0 From 6d8066afa2d767350753b3a64dbe335455bcce8f Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 29 Apr 2024 10:59:36 +0200 Subject: [PATCH 105/272] Add matter during onboarding (#116163) * Add matter during onboarding * test_zeroconf_not_onboarded_running * test_zeroconf_not_onboarded_installed * test_zeroconf_not_onboarded_not_installed * test_zeroconf_discovery_not_onboarded_not_supervisor * Clean up * Add udp address * Test zeroconf udp info too * test_addon_installed_failures_zeroconf * test_addon_running_failures_zeroconf * test_addon_not_installed_failures_zeroconf * Clean up stale changes * Set unique id for discovery step * Fix tests for background flow * Fix flow running in background * Test already discovered zeroconf * Mock unload entry --- .../components/matter/config_flow.py | 27 +- homeassistant/components/matter/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 5 + tests/components/matter/test_config_flow.py | 414 +++++++++++++++++- 4 files changed, 436 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/matter/config_flow.py b/homeassistant/components/matter/config_flow.py index 7dc06807a98..b079dcd9b54 100644 --- a/homeassistant/components/matter/config_flow.py +++ b/homeassistant/components/matter/config_flow.py @@ -17,6 +17,8 @@ from homeassistant.components.hassio import ( HassioServiceInfo, is_hassio, ) +from homeassistant.components.onboarding import async_is_onboarded +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant @@ -64,6 +66,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Set up flow instance.""" + self._running_in_background = False self.ws_address: str | None = None # If we install the add-on we should uninstall it on entry remove. self.integration_created_addon = False @@ -78,7 +81,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): if not self.install_task: self.install_task = self.hass.async_create_task(self._async_install_addon()) - if not self.install_task.done(): + if not self._running_in_background and not self.install_task.done(): return self.async_show_progress( step_id="install_addon", progress_action="install_addon", @@ -89,12 +92,16 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.install_task except AddonError as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_install_failed() return self.async_show_progress_done(next_step_id="install_failed") finally: self.install_task = None self.integration_created_addon = True + if self._running_in_background: + return await self.async_step_start_addon() return self.async_show_progress_done(next_step_id="start_addon") async def async_step_install_failed( @@ -125,7 +132,7 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): """Start Matter Server add-on.""" if not self.start_task: self.start_task = self.hass.async_create_task(self._async_start_addon()) - if not self.start_task.done(): + if not self._running_in_background and not self.start_task.done(): return self.async_show_progress( step_id="start_addon", progress_action="start_addon", @@ -136,10 +143,14 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): await self.start_task except (FailedConnect, AddonError, AbortFlow) as err: LOGGER.error(err) + if self._running_in_background: + return await self.async_step_start_failed() return self.async_show_progress_done(next_step_id="start_failed") finally: self.start_task = None + if self._running_in_background: + return await self.async_step_finish_addon_setup() return self.async_show_progress_done(next_step_id="finish_addon_setup") async def async_step_start_failed( @@ -223,6 +234,18 @@ class MatterConfigFlow(ConfigFlow, domain=DOMAIN): step_id="manual", data_schema=get_manual_schema(user_input), errors=errors ) + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + if not async_is_onboarded(self.hass) and is_hassio(self.hass): + await self._async_handle_discovery_without_unique_id() + self._running_in_background = True + return await self.async_step_on_supervisor( + user_input={CONF_USE_ADDON: True} + ) + return await self._async_step_discovery_without_unique_id() + async def async_step_hassio( self, discovery_info: HassioServiceInfo ) -> ConfigFlowResult: diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 0e27eb36f85..b3acc0d547c 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", "requirements": ["python-matter-server==5.7.0"], - "zeroconf": ["_matter._tcp.local."] + "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3441026994b..7b1bbff9de0 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -608,6 +608,11 @@ ZEROCONF = { "domain": "matter", }, ], + "_matterc._udp.local.": [ + { + "domain": "matter", + }, + ], "_mediaremotetv._tcp.local.": [ { "domain": "apple_tv", diff --git a/tests/components/matter/test_config_flow.py b/tests/components/matter/test_config_flow.py index 33e94a743f7..39ae40172c1 100644 --- a/tests/components/matter/test_config_flow.py +++ b/tests/components/matter/test_config_flow.py @@ -24,6 +24,37 @@ ADDON_DISCOVERY_INFO = { "host": "host1", "port": 5581, } +ZEROCONF_INFO_TCP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matter._tcp.local.", + name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", + properties={"SII": "3300", "SAI": "1100", "T": "0"}, +) + +ZEROCONF_INFO_UDP = ZeroconfServiceInfo( + ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), + ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], + port=5540, + hostname="CDEFGHIJ12345678.local.", + type="_matterc._udp.local.", + name="ABCDEFGH123456789._matterc._udp.local.", + properties={ + "VP": "4874+77", + "DT": "21", + "DN": "Eve Door", + "SII": "3300", + "SAI": "1100", + "T": "0", + "D": "183", + "CM": "2", + "RI": "0400530980B950D59BF473CFE42BD7DDBF2D", + "PH": "36", + "PI": None, + }, +) @pytest.fixture(name="setup_entry", autouse=True) @@ -35,6 +66,15 @@ def setup_entry_fixture() -> Generator[AsyncMock, None, None]: yield mock_setup_entry +@pytest.fixture(name="unload_entry", autouse=True) +def unload_entry_fixture() -> Generator[AsyncMock, None, None]: + """Mock entry unload.""" + with patch( + "homeassistant.components.matter.async_unload_entry", return_value=True + ) as mock_unload_entry: + yield mock_unload_entry + + @pytest.fixture(name="client_connect", autouse=True) def client_connect_fixture() -> Generator[AsyncMock, None, None]: """Mock server version.""" @@ -80,6 +120,16 @@ def addon_setup_time_fixture() -> Generator[int, None, None]: yield addon_setup_time +@pytest.fixture(name="not_onboarded") +def mock_onboarded_fixture() -> Generator[MagicMock, None, None]: + """Mock that Home Assistant is not yet onboarded.""" + with patch( + "homeassistant.components.matter.config_flow.async_is_onboarded", + return_value=False, + ) as mock_onboarded: + yield mock_onboarded + + async def test_manual_create_entry( hass: HomeAssistant, client_connect: AsyncMock, @@ -179,24 +229,18 @@ async def test_manual_already_configured( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) async def test_zeroconf_discovery( hass: HomeAssistant, client_connect: AsyncMock, setup_entry: AsyncMock, + zeroconf_info: ZeroconfServiceInfo, ) -> None: """Test flow started from Zeroconf discovery.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6"), - ip_addresses=[ip_address("fd11:be53:8d46:0:729e:5a4f:539d:1ee6")], - port=5540, - hostname="CDEFGHIJ12345678.local.", - type="_matter._tcp.local.", - name="ABCDEFGH123456789-0000000012345678._matter._tcp.local.", - properties={"SII": "3300", "SAI": "1100", "T": "0"}, - ), + data=zeroconf_info, ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "manual" @@ -221,6 +265,185 @@ async def test_zeroconf_discovery( assert setup_entry.call_count == 1 +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_zeroconf_discovery_not_onboarded_not_supervisor( + hass: HomeAssistant, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow started from Zeroconf discovery when not onboarded.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "manual" + assert result["errors"] is None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "url": "ws://localhost:5580/ws", + }, + ) + await hass.async_block_till_done() + + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://localhost:5580/ws", + "integration_created_addon": False, + "use_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_already_discovered( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and already discovered.""" + result_flow_1 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + result_flow_2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + assert result_flow_2["type"] is FlowResultType.ABORT + assert result_flow_2["reason"] == "already_configured" + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result_flow_1["type"] is FlowResultType.CREATE_ENTRY + assert result_flow_1["title"] == "Matter" + assert result_flow_1["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_running( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_running: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on running.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_installed: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": False, + } + assert setup_entry.call_count == 1 + + +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) +async def test_zeroconf_not_onboarded_not_installed( + hass: HomeAssistant, + supervisor: MagicMock, + addon_info: AsyncMock, + addon_store_info: AsyncMock, + addon_not_installed: AsyncMock, + install_addon: AsyncMock, + start_addon: AsyncMock, + client_connect: AsyncMock, + setup_entry: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test flow Zeroconf discovery when not onboarded and add-on not installed.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 0 + assert addon_store_info.call_count == 2 + assert install_addon.call_args == call(hass, "core_matter_server") + assert start_addon.call_args == call(hass, "core_matter_server") + assert client_connect.call_count == 1 + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Matter" + assert result["data"] == { + "url": "ws://host1:5581/ws", + "use_addon": True, + "integration_created_addon": True, + } + assert setup_entry.call_count == 1 + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_supervisor_discovery( hass: HomeAssistant, @@ -702,6 +925,90 @@ async def test_addon_running_failures( assert result["reason"] == abort_reason +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "discovery_info_error", + "client_connect_error", + "addon_info_error", + "abort_reason", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + None, + "cannot_connect", + True, + True, + ), + ( + None, + None, + None, + None, + "addon_get_discovery_info_failed", + True, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + None, + HassioAPIError(), + "addon_info_failed", + False, + False, + ), + ], +) +async def test_addon_running_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_running: AsyncMock, + addon_info: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + discovery_info_error: Exception | None, + client_connect_error: Exception | None, + addon_info_error: Exception | None, + abort_reason: str, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test all failures when add-on is running and not onboarded.""" + get_addon_discovery_info.side_effect = discovery_info_error + client_connect.side_effect = client_connect_error + addon_info.side_effect = addon_info_error + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf_info, + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == abort_reason + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_running_already_configured( hass: HomeAssistant, @@ -854,6 +1161,71 @@ async def test_addon_installed_failures( assert result["reason"] == "addon_start_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +@pytest.mark.parametrize( + ( + "discovery_info", + "start_addon_error", + "client_connect_error", + "discovery_info_called", + "client_connect_called", + ), + [ + ( + {"config": ADDON_DISCOVERY_INFO}, + HassioAPIError(), + None, + False, + False, + ), + ( + {"config": ADDON_DISCOVERY_INFO}, + None, + CannotConnect(Exception("Boom")), + True, + True, + ), + ( + None, + None, + None, + True, + False, + ), + ], +) +async def test_addon_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_installed: AsyncMock, + addon_info: AsyncMock, + start_addon: AsyncMock, + get_addon_discovery_info: AsyncMock, + client_connect: AsyncMock, + start_addon_error: Exception | None, + client_connect_error: Exception | None, + discovery_info_called: bool, + client_connect_called: bool, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on start failure when add-on is installed and not onboarded.""" + start_addon.side_effect = start_addon_error + client_connect.side_effect = client_connect_error + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert addon_info.call_count == 1 + assert start_addon.call_args == call(hass, "core_matter_server") + assert get_addon_discovery_info.called is discovery_info_called + assert client_connect.called is client_connect_called + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_start_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_installed_already_configured( hass: HomeAssistant, @@ -985,6 +1357,30 @@ async def test_addon_not_installed_failures( assert result["reason"] == "addon_install_failed" +@pytest.mark.parametrize("zeroconf_info", [ZEROCONF_INFO_TCP, ZEROCONF_INFO_UDP]) +async def test_addon_not_installed_failures_zeroconf( + hass: HomeAssistant, + supervisor: MagicMock, + addon_not_installed: AsyncMock, + addon_info: AsyncMock, + install_addon: AsyncMock, + not_onboarded: MagicMock, + zeroconf_info: ZeroconfServiceInfo, +) -> None: + """Test add-on install failure.""" + install_addon.side_effect = HassioAPIError() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf_info + ) + await hass.async_block_till_done() + + assert install_addon.call_args == call(hass, "core_matter_server") + assert addon_info.call_count == 0 + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "addon_install_failed" + + @pytest.mark.parametrize("discovery_info", [{"config": ADDON_DISCOVERY_INFO}]) async def test_addon_not_installed_already_configured( hass: HomeAssistant, From 2c46db16d4cee8863b531aa7e4feffb8b56509fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 27 Apr 2024 07:08:29 -0500 Subject: [PATCH 106/272] Fix script in restart mode that is fired from the same trigger (#116247) --- homeassistant/helpers/script.py | 20 +++--- tests/components/automation/test_init.py | 82 +++++++++++++++++++++++- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d925bf215ab..d739fbfef98 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1692,7 +1692,7 @@ class Script: script_stack = script_stack_cv.get() if ( self.script_mode in (SCRIPT_MODE_RESTART, SCRIPT_MODE_QUEUED) - and (script_stack := script_stack_cv.get()) is not None + and script_stack is not None and id(self) in script_stack ): script_execution_set("disallowed_recursion_detected") @@ -1706,15 +1706,19 @@ class Script: run = cls( self._hass, self, cast(dict, variables), context, self._log_exceptions ) + has_existing_runs = bool(self._runs) self._runs.append(run) - if self.script_mode == SCRIPT_MODE_RESTART: + if self.script_mode == SCRIPT_MODE_RESTART and has_existing_runs: # When script mode is SCRIPT_MODE_RESTART, first add the new run and then # stop any other runs. If we stop other runs first, self.is_running will # return false after the other script runs were stopped until our task - # resumes running. + # resumes running. Its important that we check if there are existing + # runs before sleeping as otherwise if two runs are started at the exact + # same time they will cancel each other out. self._log("Restarting") # Important: yield to the event loop to allow the script to start in case - # the script is restarting itself. + # the script is restarting itself so it ends up in the script stack and + # the recursion check above will prevent the script from running. await asyncio.sleep(0) await self.async_stop(update_state=False, spare=run) @@ -1730,9 +1734,7 @@ class Script: self._changed() raise - async def _async_stop( - self, aws: list[asyncio.Task], update_state: bool, spare: _ScriptRun | None - ) -> None: + async def _async_stop(self, aws: list[asyncio.Task], update_state: bool) -> None: await asyncio.wait(aws) if update_state: self._changed() @@ -1749,9 +1751,7 @@ class Script: ] if not aws: return - await asyncio.shield( - create_eager_task(self._async_stop(aws, update_state, spare)) - ) + await asyncio.shield(create_eager_task(self._async_stop(aws, update_state))) async def _async_get_condition(self, config): if isinstance(config, template.Template): diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 61e6d0e4660..edf0eff878b 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -8,7 +8,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.components import automation +from homeassistant.components import automation, input_boolean, script from homeassistant.components.automation import ( ATTR_SOURCE, DOMAIN, @@ -41,6 +41,7 @@ from homeassistant.core import ( ) from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.script import ( SCRIPT_MODE_CHOICES, SCRIPT_MODE_PARALLEL, @@ -2980,3 +2981,82 @@ async def test_automation_turns_off_other_automation( async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5)) await hass.async_block_till_done() assert len(calls) == 0 + + +async def test_two_automations_call_restart_script_same_time( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test two automations that call a restart mode script at the same.""" + hass.states.async_set("binary_sensor.presence", "off") + await hass.async_block_till_done() + events = [] + + @callback + def _save_event(event): + events.append(event) + + assert await async_setup_component( + hass, + input_boolean.DOMAIN, + { + input_boolean.DOMAIN: { + "test_1": None, + } + }, + ) + cancel = async_track_state_change_event(hass, "input_boolean.test_1", _save_event) + + assert await async_setup_component( + hass, + script.DOMAIN, + { + script.DOMAIN: { + "fire_toggle": { + "sequence": [ + { + "service": "input_boolean.toggle", + "target": {"entity_id": "input_boolean.test_1"}, + } + ] + }, + } + }, + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "to": "on", + }, + "action": { + "service": "script.fire_toggle", + }, + "id": "automation_0", + "mode": "single", + }, + { + "trigger": { + "platform": "state", + "entity_id": "binary_sensor.presence", + "to": "on", + }, + "action": { + "service": "script.fire_toggle", + }, + "id": "automation_1", + "mode": "single", + }, + ] + }, + ) + + hass.states.async_set("binary_sensor.presence", "on") + await hass.async_block_till_done() + assert len(events) == 2 + cancel() From abf45a0e0c9e32aa5b5e8b34d6da970b0855338c Mon Sep 17 00:00:00 2001 From: hopkins-tk Date: Sat, 27 Apr 2024 10:02:52 +0200 Subject: [PATCH 107/272] Fix Aseko binary sensors names (#116251) * Fix Aseko binary sensors names * Fix add missing key to strings.json * Fix remove setting shorthand translation key attribute * Update homeassistant/components/aseko_pool_live/strings.json --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/aseko_pool_live/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/aseko_pool_live/binary_sensor.py b/homeassistant/components/aseko_pool_live/binary_sensor.py index dbbdff38200..79953565769 100644 --- a/homeassistant/components/aseko_pool_live/binary_sensor.py +++ b/homeassistant/components/aseko_pool_live/binary_sensor.py @@ -42,6 +42,7 @@ UNIT_BINARY_SENSORS: tuple[AsekoBinarySensorEntityDescription, ...] = ( ), AsekoBinarySensorEntityDescription( key="has_error", + translation_key="error", value_fn=lambda unit: unit.has_error, device_class=BinarySensorDeviceClass.PROBLEM, ), @@ -78,7 +79,6 @@ class AsekoUnitBinarySensorEntity(AsekoEntity, BinarySensorEntity): """Initialize the unit binary sensor.""" super().__init__(unit, coordinator) self.entity_description = entity_description - self._attr_name = f"{self._device_name} {entity_description.name}" self._attr_unique_id = f"{self._unit.serial_number}_{entity_description.key}" @property From bfcffb5cb16fa62e744f3fb3f7a9b4f2367db7bb Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 28 Apr 2024 01:42:38 +0200 Subject: [PATCH 108/272] Fix no will published when mqtt is down (#116319) --- homeassistant/components/mqtt/client.py | 3 ++- tests/components/mqtt/test_init.py | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 133991ade16..7f58a21a1f1 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -711,7 +711,8 @@ class MQTT: async with self._connection_lock: self._should_reconnect = False self._async_cancel_reconnect() - self._mqttc.disconnect() + # We do not gracefully disconnect to ensure + # the broker publishes the will message @callback def async_restore_tracked_subscriptions( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 9d135b89f36..cfb8ce7ac04 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -141,17 +141,17 @@ async def test_mqtt_connects_on_home_assistant_mqtt_setup( assert mqtt_client_mock.connect.call_count == 1 -async def test_mqtt_disconnects_on_home_assistant_stop( +async def test_mqtt_does_not_disconnect_on_home_assistant_stop( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, mqtt_client_mock: MqttMockPahoClient, ) -> None: - """Test if client stops on HA stop.""" + """Test if client is not disconnected on HA stop.""" await mqtt_mock_entry() hass.bus.fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() await hass.async_block_till_done() - assert mqtt_client_mock.disconnect.call_count == 1 + assert mqtt_client_mock.disconnect.call_count == 0 async def test_mqtt_await_ack_at_disconnect( From f2a101128f862117ac7e56f84f0a32eca6e6f6c1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:13:37 -0500 Subject: [PATCH 109/272] Make discovery flow tasks background tasks (#116327) --- homeassistant/config_entries.py | 1 + homeassistant/helpers/discovery_flow.py | 2 +- tests/components/gardena_bluetooth/test_config_flow.py | 2 +- tests/components/hassio/test_init.py | 2 +- tests/components/homeassistant_yellow/test_init.py | 8 ++++---- tests/components/plex/test_config_flow.py | 2 +- 6 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 056814bbc4d..88230a78428 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1157,6 +1157,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]): cooldown=DISCOVERY_COOLDOWN, immediate=True, function=self._async_discovery, + background=True, ) async def async_wait_import_flow_initialized(self, handler: str) -> None: diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 314777733c3..e479a47ecfd 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -30,7 +30,7 @@ def async_create_flow( if not dispatcher or dispatcher.started: if init_coro := _async_init_flow(hass, domain, context, data): - hass.async_create_task( + hass.async_create_background_task( init_coro, f"discovery flow {domain} {context}", eager_start=True ) return diff --git a/tests/components/gardena_bluetooth/test_config_flow.py b/tests/components/gardena_bluetooth/test_config_flow.py index 7707a13180f..3b4e9c242b3 100644 --- a/tests/components/gardena_bluetooth/test_config_flow.py +++ b/tests/components/gardena_bluetooth/test_config_flow.py @@ -103,7 +103,7 @@ async def test_bluetooth( # Inject the service info will trigger the flow to start inject_bluetooth_service_info(hass, WATER_TIMER_SERVICE_INFO) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) result = next(iter(hass.config_entries.flow.async_progress_by_handler(DOMAIN))) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index da49b8d9f16..572593d642b 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -1098,7 +1098,7 @@ async def test_setup_hardware_integration( ) as mock_setup_entry, ): result = await async_setup_component(hass, "hassio", {"hassio": {}}) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert result assert aioclient_mock.call_count == 19 diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 0631c2cb983..ec3ba4e7005 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -44,7 +44,7 @@ async def test_setup_entry( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 @@ -90,7 +90,7 @@ async def test_setup_zha(hass: HomeAssistant, addon_store_info) -> None: ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA @@ -144,7 +144,7 @@ async def test_setup_zha_multipan( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA @@ -198,7 +198,7 @@ async def test_setup_zha_multipan_other_device( ), ): assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_get_os_info.mock_calls) == 1 # Finish setting up ZHA diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 33e1b3637d8..5f2531992d4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -722,7 +722,7 @@ async def test_integration_discovery(hass: HomeAssistant) -> None: with patch("homeassistant.components.plex.config_flow.GDM", return_value=mock_gdm): await config_flow.async_discover(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) flows = hass.config_entries.flow.async_progress() From d1e74710940eeded63d82b79995914da459ab543 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:13:51 -0500 Subject: [PATCH 110/272] Prevent setup retry from delaying shutdown (#116328) --- homeassistant/config_entries.py | 2 +- .../components/gardena_bluetooth/test_init.py | 2 +- .../specific_devices/test_ecobee3.py | 1 + .../homekit_controller/test_init.py | 6 +++-- tests/components/teslemetry/test_init.py | 2 +- tests/components/wiz/test_init.py | 4 ++-- tests/components/yeelight/test_init.py | 22 +++++++++---------- tests/components/zha/test_init.py | 3 ++- 8 files changed, 23 insertions(+), 19 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 88230a78428..73e1d8debd6 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -698,7 +698,7 @@ class ConfigEntry: # Check again when we fire in case shutdown # has started so we do not block shutdown if not hass.is_stopping: - hass.async_create_task( + hass.async_create_background_task( self._async_setup_retry(hass), f"config entry retry {self.domain} {self.title}", eager_start=True, diff --git a/tests/components/gardena_bluetooth/test_init.py b/tests/components/gardena_bluetooth/test_init.py index 1f294c6169d..53688846c07 100644 --- a/tests/components/gardena_bluetooth/test_init.py +++ b/tests/components/gardena_bluetooth/test_init.py @@ -57,6 +57,6 @@ async def test_setup_retry( mock_client.read_char.side_effect = original_read_char async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert mock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 3f93ca1a896..059993e3bef 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -203,6 +203,7 @@ async def test_ecobee3_setup_connection_failure( # We just advance time by 5 minutes so that the retry happens, rather # than manually invoking async_setup_entry. await time_changed(hass, 5 * 60) + await hass.async_block_till_done(wait_background_tasks=True) climate = entity_registry.async_get("climate.homew") assert climate.unique_id == "00:00:00:00:00:00_1_16" diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 59fdf555a50..9d2022f6b1c 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -160,7 +160,7 @@ async def test_offline_device_raises(hass: HomeAssistant, controller) -> None: is_connected = True async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.testdevice").state == STATE_OFF @@ -217,16 +217,18 @@ async def test_ble_device_only_checks_is_available( is_available = True async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert hass.states.get("light.testdevice").state == STATE_OFF is_available = False async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("light.testdevice").state == STATE_UNAVAILABLE is_available = True async_fire_time_changed(hass, utcnow() + timedelta(hours=1)) + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get("light.testdevice").state == STATE_OFF diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index fb405e2ee03..f21a421ed6e 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -74,7 +74,7 @@ async def test_vehicle_first_refresh( # Wait for the retry freezer.tick(timedelta(seconds=60)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Verify we have loaded assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/wiz/test_init.py b/tests/components/wiz/test_init.py index 3fa369c4d9d..c3438aed1b2 100644 --- a/tests/components/wiz/test_init.py +++ b/tests/components/wiz/test_init.py @@ -32,9 +32,9 @@ async def test_setup_retry(hass: HomeAssistant) -> None: bulb.getMac = AsyncMock(return_value=FAKE_MAC) with _patch_discovery(), _patch_wizlight(device=bulb): - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) async_fire_time_changed(hass, utcnow() + datetime.timedelta(minutes=15)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py index af442d1c8d0..0bff635fb6e 100644 --- a/tests/components/yeelight/test_init.py +++ b/tests/components/yeelight/test_init.py @@ -69,7 +69,7 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # The discovery should update the ip address assert config_entry.data[CONF_HOST] == IP_ADDRESS @@ -78,7 +78,7 @@ async def test_ip_changes_fallback_discovery(hass: HomeAssistant) -> None: with patch(f"{MODULE}.AsyncBulb", return_value=mocked_bulb), _patch_discovery(): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED binary_sensor_entity_id = ENTITY_BINARY_SENSOR_TEMPLATE.format( @@ -362,7 +362,7 @@ async def test_bulb_off_while_adding_in_ha(hass: HomeAssistant) -> None: _patch_discovery_interval(), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -380,7 +380,7 @@ async def test_async_listen_error_late_discovery( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY await hass.async_block_till_done() @@ -388,7 +388,7 @@ async def test_async_listen_error_late_discovery( with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED assert config_entry.data[CONF_DETECTED_MODEL] == MODEL @@ -411,7 +411,7 @@ async def test_fail_to_fetch_initial_state( await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.SETUP_RETRY await hass.async_block_till_done() @@ -419,7 +419,7 @@ async def test_fail_to_fetch_initial_state( with _patch_discovery(), patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -502,7 +502,7 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.data[CONF_ID] == ID async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with ( _patch_discovery(), @@ -511,7 +511,7 @@ async def test_async_setup_with_missing_id(hass: HomeAssistant) -> None: patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED @@ -535,7 +535,7 @@ async def test_async_setup_with_missing_unique_id(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY assert config_entry.unique_id == ID async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=2)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with ( _patch_discovery(), @@ -544,7 +544,7 @@ async def test_async_setup_with_missing_unique_id(hass: HomeAssistant) -> None: patch(f"{MODULE}.AsyncBulb", return_value=_mocked_bulb()), ): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=4)) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 99d6a78924b..70ba88ee6e7 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -255,10 +255,11 @@ async def test_zha_retry_unique_ids( lambda hass, delay, action: async_call_later(hass, 0, action), ): await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Wait for the config entry setup to retry await asyncio.sleep(0.1) + await hass.async_block_till_done(wait_background_tasks=True) assert len(mock_connect.mock_calls) == 2 From 624eed4b83b2d5612b86ee2f9182cc17e6dfc1a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:30 -0500 Subject: [PATCH 111/272] Fix august delaying shutdown (#116329) --- homeassistant/components/august/subscriber.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 7294f8bc90f..bec8e2f0b97 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -47,7 +47,9 @@ class AugustSubscriberMixin: @callback def _async_scheduled_refresh(self, now: datetime) -> None: """Call the refresh method.""" - self._hass.async_create_task(self._async_refresh(now), eager_start=True) + self._hass.async_create_background_task( + self._async_refresh(now), name=f"{self} schedule refresh", eager_start=True + ) @callback def _async_cancel_update_interval(self, _: Event | None = None) -> None: From 1309fc5eda6f063b1c65213b2f0ff341c4634c71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:04 -0500 Subject: [PATCH 112/272] Fix unifiprotect delaying shutdown if websocket if offline (#116331) --- homeassistant/components/unifiprotect/data.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index c0a6d65ff7a..55ddf91d3cb 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -269,7 +269,12 @@ class ProtectData: this will be a no-op. If the websocket is disconnected, this will trigger a reconnect and refresh. """ - self._hass.async_create_task(self.async_refresh(), eager_start=True) + self._entry.async_create_background_task( + self._hass, + self.async_refresh(), + name=f"{DOMAIN} {self._entry.title} refresh", + eager_start=True, + ) @callback def async_subscribe_device_id( From c3cb79e0e9b4140ecee9cf30a28e30bd40e55827 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:14:45 -0500 Subject: [PATCH 113/272] Fix wemo push updates delaying shutdown (#116333) --- homeassistant/components/wemo/wemo_device.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 148646736bc..7326e0b42f5 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -5,6 +5,7 @@ from __future__ import annotations import asyncio from dataclasses import dataclass, fields from datetime import timedelta +from functools import partial import logging from typing import TYPE_CHECKING, Literal @@ -130,7 +131,14 @@ class DeviceCoordinator(DataUpdateCoordinator[None]): # pylint: disable=hass-en ) else: updated = self.wemo.subscription_update(event_type, params) - self.hass.create_task(self._async_subscription_callback(updated)) + self.hass.loop.call_soon_threadsafe( + partial( + self.hass.async_create_background_task, + self._async_subscription_callback(updated), + f"{self.name} subscription_callback", + eager_start=True, + ) + ) async def async_shutdown(self) -> None: """Unregister push subscriptions and remove from coordinators dict.""" From c4c21bc8ea19d99ba391f741c8a86a37790dbd00 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:57:31 -0500 Subject: [PATCH 114/272] Fix bluetooth adapter discovery delaying startup and shutdown (#116335) --- homeassistant/components/bluetooth/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 4768d58379a..acc38cad58b 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -152,6 +152,7 @@ async def _async_start_adapter_discovery( cooldown=BLUETOOTH_DISCOVERY_COOLDOWN_SECONDS, immediate=False, function=_async_rediscover_adapters, + background=True, ) @hass_callback From 6786479a816af20c31df215cf2d510299a971e12 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 08:54:34 -0500 Subject: [PATCH 115/272] Fix sonos events delaying shutdown (#116337) --- homeassistant/components/sonos/speaker.py | 22 ++++++++++++++-------- tests/components/sonos/test_switch.py | 4 ++-- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 667e2bb405f..e2529ddfe94 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -407,8 +407,8 @@ class SonosSpeaker: @callback def async_renew_failed(self, exception: Exception) -> None: """Handle a failed subscription renewal.""" - self.hass.async_create_task( - self._async_renew_failed(exception), eager_start=True + self.hass.async_create_background_task( + self._async_renew_failed(exception), "sonos renew failed", eager_start=True ) async def _async_renew_failed(self, exception: Exception) -> None: @@ -451,16 +451,20 @@ class SonosSpeaker: """Add the soco instance associated with the event to the callback.""" if "alarm_list_version" not in event.variables: return - self.hass.async_create_task( - self.alarms.async_process_event(event, self), eager_start=True + self.hass.async_create_background_task( + self.alarms.async_process_event(event, self), + "sonos process event", + eager_start=True, ) @callback def async_dispatch_device_properties(self, event: SonosEvent) -> None: """Update device properties from an event.""" self.event_stats.process(event) - self.hass.async_create_task( - self.async_update_device_properties(event), eager_start=True + self.hass.async_create_background_task( + self.async_update_device_properties(event), + "sonos device properties", + eager_start=True, ) async def async_update_device_properties(self, event: SonosEvent) -> None: @@ -483,8 +487,10 @@ class SonosSpeaker: return if "container_update_i_ds" not in event.variables: return - self.hass.async_create_task( - self.favorites.async_process_event(event, self), eager_start=True + self.hass.async_create_background_task( + self.favorites.async_process_event(event, self), + "sonos dispatch favorites", + eager_start=True, ) @callback diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index eb31d991a3a..d6814886d55 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -157,7 +157,7 @@ async def test_alarm_create_delete( alarm_event.variables["alarm_list_version"] = two_alarms["CurrentAlarmListVersion"] sub_callback(event=alarm_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" in entity_registry.entities @@ -169,7 +169,7 @@ async def test_alarm_create_delete( alarm_clock.ListAlarms.return_value = one_alarm sub_callback(event=alarm_event) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.sonos_alarm_15" not in entity_registry.entities From 66538ba34eeffd00675afaa3959a77c3188a6af4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 17:29:00 -0500 Subject: [PATCH 116/272] Add thread safety checks to async_create_task (#116339) * Add thread safety checks to async_create_task Calling async_create_task from a thread almost always results in an fast crash. Since most internals are using async_create_background_task or other task APIs, and this is the one integrations seem to get wrong the most, add a thread safety check here * Add thread safety checks to async_create_task Calling async_create_task from a thread almost always results in an fast crash. Since most internals are using async_create_background_task or other task APIs, and this is the one integrations seem to get wrong the most, add a thread safety check here * missed one * Update homeassistant/core.py * fix mocks * one more internal * more places where internal can be used * more places where internal can be used * more places where internal can be used * internal one more place since this is high volume and was already eager_start --- homeassistant/bootstrap.py | 2 +- homeassistant/config_entries.py | 4 +- homeassistant/core.py | 37 ++++++++++++++++++- homeassistant/helpers/entity.py | 2 +- homeassistant/helpers/entity_component.py | 2 +- homeassistant/helpers/entity_platform.py | 2 +- homeassistant/helpers/integration_platform.py | 4 +- homeassistant/helpers/intent.py | 2 +- homeassistant/helpers/restore_state.py | 4 +- homeassistant/helpers/script.py | 4 +- homeassistant/helpers/storage.py | 2 +- homeassistant/setup.py | 2 +- tests/common.py | 8 ++-- tests/test_core.py | 18 +++++++-- 14 files changed, 70 insertions(+), 23 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index cbc808eb0fa..fc5eedffc39 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -731,7 +731,7 @@ async def async_setup_multi_components( # to wait to be imported, and the sooner we can get the base platforms # loaded the sooner we can start loading the rest of the integrations. futures = { - domain: hass.async_create_task( + domain: hass.async_create_task_internal( async_setup_component(hass, domain, config), f"setup component {domain}", eager_start=True, diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 73e1d8debd6..619b2a4b48a 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1087,7 +1087,7 @@ class ConfigEntry: target: target to call. """ - task = hass.async_create_task( + task = hass.async_create_task_internal( target, f"{name} {self.title} {self.domain} {self.entry_id}", eager_start ) if eager_start and task.done(): @@ -1643,7 +1643,7 @@ class ConfigEntries: # starting a new flow with the 'unignore' step. If the integration doesn't # implement async_step_unignore then this will be a no-op. if entry.source == SOURCE_IGNORE: - self.hass.async_create_task( + self.hass.async_create_task_internal( self.hass.config_entries.flow.async_init( entry.domain, context={"source": SOURCE_UNIGNORE}, diff --git a/homeassistant/core.py b/homeassistant/core.py index a3150adc221..2b1b9756a50 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -785,7 +785,9 @@ class HomeAssistant: target: target to call. """ self.loop.call_soon_threadsafe( - functools.partial(self.async_create_task, target, name, eager_start=True) + functools.partial( + self.async_create_task_internal, target, name, eager_start=True + ) ) @callback @@ -800,6 +802,37 @@ class HomeAssistant: This method must be run in the event loop. If you are using this in your integration, use the create task methods on the config entry instead. + target: target to call. + """ + # We turned on asyncio debug in April 2024 in the dev containers + # in the hope of catching some of the issues that have been + # reported. It will take a while to get all the issues fixed in + # custom components. + # + # In 2025.5 we should guard the `verify_event_loop_thread` + # check with a check for the `hass.config.debug` flag being set as + # long term we don't want to be checking this in production + # environments since it is a performance hit. + self.verify_event_loop_thread("async_create_task") + return self.async_create_task_internal(target, name, eager_start) + + @callback + def async_create_task_internal( + self, + target: Coroutine[Any, Any, _R], + name: str | None = None, + eager_start: bool = True, + ) -> asyncio.Task[_R]: + """Create a task from within the event loop, internal use only. + + This method is intended to only be used by core internally + and should not be considered a stable API. We will make + breaking change to this function in the future and it + should not be used in integrations. + + This method must be run in the event loop. If you are using this in your + integration, use the create task methods on the config entry instead. + target: target to call. """ if eager_start: @@ -2695,7 +2728,7 @@ class ServiceRegistry: coro = self._execute_service(handler, service_call) if not blocking: - self._hass.async_create_task( + self._hass.async_create_task_internal( self._run_service_call_catch_exceptions(coro, service_call), f"service call background {service_call.domain}.{service_call.service}", eager_start=True, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index a91b4c32d21..6352a56dc90 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -1490,7 +1490,7 @@ class Entity( is_remove = action == "remove" self._removed_from_registry = is_remove if action == "update" or is_remove: - self.hass.async_create_task( + self.hass.async_create_task_internal( self._async_process_registry_update_or_remove(event), eager_start=True ) diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index f467b5683a9..eb54d83e1dd 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -146,7 +146,7 @@ class EntityComponent(Generic[_EntityT]): # Look in config for Domain, Domain 2, Domain 3 etc and load them for p_type, p_config in conf_util.config_per_platform(config, self.domain): if p_type is not None: - self.hass.async_create_task( + self.hass.async_create_task_internal( self.async_setup_platform(p_type, p_config), f"EntityComponent setup platform {p_type} {self.domain}", eager_start=True, diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 2b9a5d436ed..f95c0a0b66a 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -477,7 +477,7 @@ class EntityPlatform: self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: """Schedule adding entities for a single platform async.""" - task = self.hass.async_create_task( + task = self.hass.async_create_task_internal( self.async_add_entities(new_entities, update_before_add=update_before_add), f"EntityPlatform async_add_entities {self.domain}.{self.platform_name}", eager_start=True, diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index be525b384e0..fbd26019b64 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -85,7 +85,7 @@ def _async_integration_platform_component_loaded( # At least one of the platforms is not loaded, we need to load them # so we have to fall back to creating a task. - hass.async_create_task( + hass.async_create_task_internal( _async_process_integration_platforms_for_component( hass, integration, platforms_that_exist, integration_platforms_by_name ), @@ -206,7 +206,7 @@ async def async_process_integration_platforms( # We use hass.async_create_task instead of asyncio.create_task because # we want to make sure that startup waits for the task to complete. # - future = hass.async_create_task( + future = hass.async_create_task_internal( _async_process_integration_platforms( hass, platform_name, top_level_components.copy(), process_job ), diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 0ddf4a1e329..119142ec14a 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -659,7 +659,7 @@ class DynamicServiceIntentHandler(IntentHandler): ) await self._run_then_background( - hass.async_create_task( + hass.async_create_task_internal( hass.services.async_call( domain, service, diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index 40c898fe1d2..2b3afc2f57b 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -236,7 +236,9 @@ class RestoreStateData: # Dump the initial states now. This helps minimize the risk of having # old states loaded by overwriting the last states once Home Assistant # has started and the old states have been read. - self.hass.async_create_task(_async_dump_states(), "RestoreStateData dump") + self.hass.async_create_task_internal( + _async_dump_states(), "RestoreStateData dump" + ) # Dump states periodically cancel_interval = async_track_time_interval( diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index d739fbfef98..1bbe7749ff7 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -734,7 +734,7 @@ class _ScriptRun: ) trace_set_result(params=params, running_script=running_script) response_data = await self._async_run_long_action( - self._hass.async_create_task( + self._hass.async_create_task_internal( self._hass.services.async_call( **params, blocking=True, @@ -1208,7 +1208,7 @@ class _ScriptRun: async def _async_run_script(self, script: Script) -> None: """Execute a script.""" result = await self._async_run_long_action( - self._hass.async_create_task( + self._hass.async_create_task_internal( script.async_run(self._variables, self._context), eager_start=True ) ) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 20054274275..315d28e06e6 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -468,7 +468,7 @@ class Store(Generic[_T]): # wrote. Reschedule the timer to the next write time. self._async_reschedule_delayed_write(self._next_write_time) return - self.hass.async_create_task( + self.hass.async_create_task_internal( self._async_callback_delayed_write(), eager_start=True ) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index fab70e31d9d..7ba51b644e5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -600,7 +600,7 @@ def _async_when_setup( _LOGGER.exception("Error handling when_setup callback for %s", component) if component in hass.config.components: - hass.async_create_task( + hass.async_create_task_internal( when_setup(), f"when setup {component}", eager_start=True ) return diff --git a/tests/common.py b/tests/common.py index b5fe0f7bae1..a3af2a3103b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -234,7 +234,7 @@ async def async_test_home_assistant( orig_async_add_job = hass.async_add_job orig_async_add_executor_job = hass.async_add_executor_job - orig_async_create_task = hass.async_create_task + orig_async_create_task_internal = hass.async_create_task_internal orig_tz = dt_util.DEFAULT_TIME_ZONE def async_add_job(target, *args, eager_start: bool = False): @@ -263,18 +263,18 @@ async def async_test_home_assistant( return orig_async_add_executor_job(target, *args) - def async_create_task(coroutine, name=None, eager_start=True): + def async_create_task_internal(coroutine, name=None, eager_start=True): """Create task.""" if isinstance(coroutine, Mock) and not isinstance(coroutine, AsyncMock): fut = asyncio.Future() fut.set_result(None) return fut - return orig_async_create_task(coroutine, name, eager_start) + return orig_async_create_task_internal(coroutine, name, eager_start) hass.async_add_job = async_add_job hass.async_add_executor_job = async_add_executor_job - hass.async_create_task = async_create_task + hass.async_create_task_internal = async_create_task_internal hass.data[loader.DATA_CUSTOM_COMPONENTS] = {} diff --git a/tests/test_core.py b/tests/test_core.py index a553d5bbbed..66b5be718b1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -329,7 +329,7 @@ async def test_async_create_task_schedule_coroutine() -> None: async def job(): pass - ha.HomeAssistant.async_create_task(hass, job(), eager_start=False) + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=False) assert len(hass.loop.call_soon.mock_calls) == 0 assert len(hass.loop.create_task.mock_calls) == 1 assert len(hass.add_job.mock_calls) == 0 @@ -342,7 +342,7 @@ async def test_async_create_task_eager_start_schedule_coroutine() -> None: async def job(): pass - ha.HomeAssistant.async_create_task(hass, job(), eager_start=True) + ha.HomeAssistant.async_create_task_internal(hass, job(), eager_start=True) # Should create the task directly since 3.12 supports eager_start assert len(hass.loop.create_task.mock_calls) == 0 assert len(hass.add_job.mock_calls) == 0 @@ -355,7 +355,7 @@ async def test_async_create_task_schedule_coroutine_with_name() -> None: async def job(): pass - task = ha.HomeAssistant.async_create_task( + task = ha.HomeAssistant.async_create_task_internal( hass, job(), "named task", eager_start=False ) assert len(hass.loop.call_soon.mock_calls) == 0 @@ -3480,3 +3480,15 @@ async def test_async_remove_thread_safety(hass: HomeAssistant) -> None: await hass.async_add_executor_job( hass.services.async_remove, "test_domain", "test_service" ) + + +async def test_async_create_task_thread_safety(hass: HomeAssistant) -> None: + """Test async_create_task thread safety.""" + + async def _any_coro(): + pass + + with pytest.raises( + RuntimeError, match="Detected code that calls async_create_task from a thread." + ): + await hass.async_add_executor_job(hass.async_create_task, _any_coro) From c533ca50b1d7cf5ceac79e4d5c0e013b10a9b77b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 10:36:03 -0500 Subject: [PATCH 117/272] Fix homeassistant_alerts delaying shutdown (#116340) --- homeassistant/components/homeassistant_alerts/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index ef5e330699a..5b5e758fba4 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -87,7 +87,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if not coordinator.last_update_success: return - hass.async_create_task(async_update_alerts(), eager_start=True) + hass.async_create_background_task( + async_update_alerts(), "homeassistant_alerts update", eager_start=True + ) coordinator = AlertUpdateCoordinator(hass) coordinator.async_add_listener(async_schedule_update_alerts) From 5ca91190f2c8e1aa3421c9e046659455c8157098 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 28 Apr 2024 17:34:27 +0200 Subject: [PATCH 118/272] Fix Netatmo indoor sensor (#116342) * Debug netatmo indoor sensor * Debug netatmo indoor sensor * Fix --- homeassistant/components/netatmo/sensor.py | 5 ++++- .../components/netatmo/snapshots/test_sensor.ambr | 14 +++++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index fd40bbf88b6..7d99ef9d32c 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -529,7 +529,10 @@ class NetatmoWeatherSensor(NetatmoWeatherModuleEntity, SensorEntity): @property def available(self) -> bool: """Return True if entity is available.""" - return self.device.reachable or False + return ( + self.device.reachable + or getattr(self.device, self.entity_description.netatmo_name) is not None + ) @callback def async_update_callback(self) -> None: diff --git a/tests/components/netatmo/snapshots/test_sensor.ambr b/tests/components/netatmo/snapshots/test_sensor.ambr index 0684956adb8..6ab1e4b1e1a 100644 --- a/tests/components/netatmo/snapshots/test_sensor.ambr +++ b/tests/components/netatmo/snapshots/test_sensor.ambr @@ -901,13 +901,15 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', 'friendly_name': 'Bedroom Reachability', + 'latitude': 13.377726, + 'longitude': 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_reachability', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'False', }) # --- # name: test_entity[sensor.bedroom_temperature-entry] @@ -1050,13 +1052,15 @@ 'attributes': ReadOnlyDict({ 'attribution': 'Data provided by Netatmo', 'friendly_name': 'Bedroom Wi-Fi', + 'latitude': 13.377726, + 'longitude': 52.516263, }), 'context': , 'entity_id': 'sensor.bedroom_wi_fi', 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'High', }) # --- # name: test_entity[sensor.bureau_modulate_battery-entry] @@ -6692,7 +6696,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': '27', }) # --- # name: test_entity[sensor.villa_outdoor_humidity-entry] @@ -6791,7 +6795,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'High', }) # --- # name: test_entity[sensor.villa_outdoor_reachability-entry] @@ -6838,7 +6842,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': 'unavailable', + 'state': 'False', }) # --- # name: test_entity[sensor.villa_outdoor_temperature-entry] From 0cec3781267eb510dce27568e592be58a6fd3ef9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 09:21:32 -0500 Subject: [PATCH 119/272] Fix some flapping sonos tests (#116343) --- tests/components/sonos/test_repairs.py | 1 + tests/components/sonos/test_switch.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index 49b87b272d6..2fa951c6a79 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -47,3 +47,4 @@ async def test_subscription_repair_issues( sub_callback(event) await hass.async_block_till_done() assert not issue_registry.async_get_issue(DOMAIN, SUB_FAIL_ISSUE_ID) + await hass.async_block_till_done(wait_background_tasks=True) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index d6814886d55..11ce1aa5ddb 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -122,6 +122,7 @@ async def test_switch_attributes( # Trigger subscription callback for speaker discovery await fire_zgs_event() + await hass.async_block_till_done(wait_background_tasks=True) status_light_state = hass.states.get(status_light.entity_id) assert status_light_state.state == STATE_ON From 88015986addd8ba5f0c8d5d92aa4d74b270249e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 10:57:08 -0500 Subject: [PATCH 120/272] Fix bond update delaying shutdown when push updated are not available (#116344) If push updates are not available, bond could delay shutdown. The update task should have been marked as a background task --- homeassistant/components/bond/entity.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index f547707d5f1..4495e76859d 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -128,7 +128,9 @@ class BondEntity(Entity): _FALLBACK_SCAN_INTERVAL, ) return - self.hass.async_create_task(self._async_update(), eager_start=True) + self.hass.async_create_background_task( + self._async_update(), f"{DOMAIN} {self.name} update", eager_start=True + ) async def _async_update(self) -> None: """Fetch via the API.""" From 9445b84ab5da7c63f8c204c9352d5d1b01fb99ec Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 11:19:38 -0500 Subject: [PATCH 121/272] Fix shelly delaying shutdown (#116346) --- .../components/shelly/coordinator.py | 36 +++++++++++++++---- tests/components/shelly/test_binary_sensor.py | 10 +++--- tests/components/shelly/test_climate.py | 18 +++++----- tests/components/shelly/test_coordinator.py | 16 ++++----- tests/components/shelly/test_number.py | 10 +++--- tests/components/shelly/test_sensor.py | 18 +++++----- tests/components/shelly/test_update.py | 6 ++-- 7 files changed, 69 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index bd6686198ed..d3d7b86de11 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -361,7 +361,12 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): ) -> None: """Handle device update.""" if update_type is BlockUpdateType.ONLINE: - self.hass.async_create_task(self._async_device_connect(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_device_connect(), + "block device online", + eager_start=True, + ) elif update_type is BlockUpdateType.COAP_PERIODIC: self._push_update_failures = 0 ir.async_delete_issue( @@ -654,12 +659,24 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): ) -> None: """Handle device update.""" if update_type is RpcUpdateType.ONLINE: - self.hass.async_create_task(self._async_device_connect(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_device_connect(), + "rpc device online", + eager_start=True, + ) elif update_type is RpcUpdateType.INITIALIZED: - self.hass.async_create_task(self._async_connected(), eager_start=True) + self.entry.async_create_background_task( + self.hass, self._async_connected(), "rpc device init", eager_start=True + ) self.async_set_updated_data(None) elif update_type is RpcUpdateType.DISCONNECTED: - self.hass.async_create_task(self._async_disconnected(), eager_start=True) + self.entry.async_create_background_task( + self.hass, + self._async_disconnected(), + "rpc device disconnected", + eager_start=True, + ) elif update_type is RpcUpdateType.STATUS: self.async_set_updated_data(None) if self.sleep_period: @@ -673,7 +690,9 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): self.device.subscribe_updates(self._async_handle_update) if self.device.initialized: # If we are already initialized, we are connected - self.hass.async_create_task(self._async_connected(), eager_start=True) + self.entry.async_create_task( + self.hass, self._async_connected(), eager_start=True + ) async def shutdown(self) -> None: """Shutdown the coordinator.""" @@ -756,4 +775,9 @@ async def async_reconnect_soon(hass: HomeAssistant, entry: ConfigEntry) -> None: and (entry_data := get_entry_data(hass).get(entry.entry_id)) and (coordinator := entry_data.rpc) ): - hass.async_create_task(coordinator.async_request_refresh(), eager_start=True) + entry.async_create_background_task( + hass, + coordinator.async_request_refresh(), + "reconnect soon", + eager_start=True, + ) diff --git a/tests/components/shelly/test_binary_sensor.py b/tests/components/shelly/test_binary_sensor.py index 624eb82f060..524bc1e8ffc 100644 --- a/tests/components/shelly/test_binary_sensor.py +++ b/tests/components/shelly/test_binary_sensor.py @@ -145,7 +145,7 @@ async def test_block_sleeping_binary_sensor( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -181,7 +181,7 @@ async def test_block_restored_sleeping_binary_sensor( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -207,7 +207,7 @@ async def test_block_restored_sleeping_binary_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -275,7 +275,7 @@ async def test_rpc_sleeping_binary_sensor( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == STATE_OFF @@ -346,7 +346,7 @@ async def test_rpc_restored_sleeping_binary_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index 9946dd7640d..a70cdef3fb1 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -70,7 +70,7 @@ async def test_climate_hvac_mode( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Test initial hvac mode - off state = hass.states.get(ENTITY_ID) @@ -131,7 +131,7 @@ async def test_climate_set_temperature( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.state == HVACMode.OFF @@ -198,7 +198,7 @@ async def test_climate_set_preset_mode( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_PRESET_MODE] == PRESET_NONE @@ -284,7 +284,7 @@ async def test_block_restored_climate( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == HVACMode.OFF assert hass.states.get(entity_id).attributes.get("temperature") == 4.0 @@ -355,7 +355,7 @@ async def test_block_restored_climate_us_customery( monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "temp", 18.2) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == HVACMode.OFF assert hass.states.get(entity_id).attributes.get("temperature") == 39 @@ -457,7 +457,7 @@ async def test_block_set_mode_connection_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -482,7 +482,7 @@ async def test_block_set_mode_auth_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED @@ -540,7 +540,7 @@ async def test_block_restored_climate_auth_error( return_value={}, side_effect=InvalidAuthError ) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED @@ -567,7 +567,7 @@ async def test_device_not_calibrated( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_status = MOCK_STATUS_COAP.copy() mock_status["calibrated"] = False diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 9f251d1e008..1e581e156c5 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -224,7 +224,7 @@ async def test_block_sleeping_device_firmware_unsupported( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED assert ( @@ -299,7 +299,7 @@ async def test_block_sleeping_device_no_periodic_updates( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) == "22.1" @@ -542,7 +542,7 @@ async def test_rpc_update_entry_sleep_period( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.data["sleep_period"] == 600 @@ -550,7 +550,7 @@ async def test_rpc_update_entry_sleep_period( monkeypatch.setitem(mock_rpc_device.status["sys"], "wakeup_period", 3600) freezer.tick(timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.data["sleep_period"] == 3600 @@ -575,14 +575,14 @@ async def test_rpc_sleeping_device_no_periodic_updates( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) == "22.9" # Move time to generate polling freezer.tick(timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000)) async_fire_time_changed(hass) - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert get_entity_state(hass, entity_id) is STATE_UNAVAILABLE @@ -599,7 +599,7 @@ async def test_rpc_sleeping_device_firmware_unsupported( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED assert ( @@ -765,7 +765,7 @@ async def test_rpc_update_entry_fw_ver( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.unique_id device = dev_reg.async_get_device( diff --git a/tests/components/shelly/test_number.py b/tests/components/shelly/test_number.py index 99ad5709d29..0b9fee9e47f 100644 --- a/tests/components/shelly/test_number.py +++ b/tests/components/shelly/test_number.py @@ -44,7 +44,7 @@ async def test_block_number_update( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -99,7 +99,7 @@ async def test_block_restored_number( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -136,7 +136,7 @@ async def test_block_restored_number_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "50" @@ -156,7 +156,7 @@ async def test_block_number_set_value( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) mock_block_device.reset_mock() await hass.services.async_call( @@ -217,7 +217,7 @@ async def test_block_set_value_auth_error( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert entry.state is ConfigEntryState.LOADED diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 6151cac10ab..ceaa9b66b8d 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -165,7 +165,7 @@ async def test_block_sleeping_sensor( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -207,7 +207,7 @@ async def test_block_restored_sleeping_sensor( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -233,7 +233,7 @@ async def test_block_restored_sleeping_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" @@ -306,7 +306,7 @@ async def test_block_not_matched_restored_sleeping_sensor( ) monkeypatch.setattr(mock_block_device, "initialized", True) mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "20.4" @@ -464,7 +464,7 @@ async def test_rpc_sleeping_sensor( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.9" @@ -503,7 +503,7 @@ async def test_rpc_restored_sleeping_sensor( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -539,7 +539,7 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -607,7 +607,7 @@ async def test_rpc_sleeping_update_entity_service( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == "22.9" @@ -657,7 +657,7 @@ async def test_block_sleeping_update_entity_service( # Make device online mock_block_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) assert hass.states.get(entity_id).state == "22.1" diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 93b0f55c415..0f26fd14d12 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -352,7 +352,7 @@ async def test_rpc_sleeping_update( # Make device online mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) state = hass.states.get(entity_id) assert state.state == STATE_ON @@ -413,7 +413,7 @@ async def test_rpc_restored_sleeping_update( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() @@ -462,7 +462,7 @@ async def test_rpc_restored_sleeping_update_no_last_state( # Make device online monkeypatch.setattr(mock_rpc_device, "initialized", True) mock_rpc_device.mock_online() - await hass.async_block_till_done() + await hass.async_block_till_done(wait_background_tasks=True) # Mock update mock_rpc_device.mock_update() From 087b6533cddd9ebd9ac8af141d3acb2d4234b2d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 28 Apr 2024 12:54:53 -0500 Subject: [PATCH 122/272] Fix another case of homeassistant_alerts delaying shutdown (#116352) --- homeassistant/components/homeassistant_alerts/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 5b5e758fba4..b33bfe5ed1e 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -101,6 +101,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cooldown=COMPONENT_LOADED_COOLDOWN, immediate=False, function=coordinator.async_refresh, + background=True, ) @callback From a61650e38f94758f6d90a8e5cc4692e3f6827e5c Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 29 Apr 2024 16:03:57 +0300 Subject: [PATCH 123/272] Prevent Shelly raising in a task (#116355) Co-authored-by: J. Nick Koston --- .../components/shelly/coordinator.py | 24 +++-- tests/components/shelly/test_coordinator.py | 96 ++++++++++++++++++- 2 files changed, 109 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d3d7b86de11..e321f393ba3 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -154,24 +154,27 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): ) self.device_id = device_entry.id - async def _async_device_connect(self) -> None: - """Connect to a Shelly Block device.""" + async def _async_device_connect_task(self) -> bool: + """Connect to a Shelly device task.""" LOGGER.debug("Connecting to Shelly Device - %s", self.name) try: await self.device.initialize() update_device_fw_info(self.hass, self.device, self.entry) except DeviceConnectionError as err: - raise UpdateFailed(f"Device disconnected: {repr(err)}") from err + LOGGER.debug( + "Error connecting to Shelly device %s, error: %r", self.name, err + ) + return False except InvalidAuthError: self.entry.async_start_reauth(self.hass) - return + return False if not self.device.firmware_supported: async_create_issue_unsupported_firmware(self.hass, self.entry) - return + return False if not self._pending_platforms: - return + return True LOGGER.debug("Device %s is online, resuming setup", self.entry.title) platforms = self._pending_platforms @@ -193,6 +196,8 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): # Resume platform setup await self.hass.config_entries.async_forward_entry_setups(self.entry, platforms) + return True + async def _async_reload_entry(self) -> None: """Reload entry.""" self._debounced_reload.async_cancel() @@ -363,7 +368,7 @@ class ShellyBlockCoordinator(ShellyCoordinatorBase[BlockDevice]): if update_type is BlockUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, - self._async_device_connect(), + self._async_device_connect_task(), "block device online", eager_start=True, ) @@ -591,7 +596,8 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if self.device.connected: return - await self._async_device_connect() + if not await self._async_device_connect_task(): + raise UpdateFailed("Device reconnect error") async def _async_disconnected(self) -> None: """Handle device disconnected.""" @@ -661,7 +667,7 @@ class ShellyRpcCoordinator(ShellyCoordinatorBase[RpcDevice]): if update_type is RpcUpdateType.ONLINE: self.entry.async_create_background_task( self.hass, - self._async_device_connect(), + self._async_device_connect_task(), "rpc device online", eager_start=True, ) diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 1e581e156c5..1dc45a98c44 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -24,10 +24,11 @@ from homeassistant.components.shelly.const import ( ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.const import ATTR_DEVICE_ID, STATE_ON, STATE_UNAVAILABLE -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, + DeviceRegistry, async_entries_for_config_entry, async_get as async_get_dev_reg, format_mac, @@ -40,10 +41,11 @@ from . import ( inject_rpc_device_event, mock_polling_rpc_update, mock_rest_update, + register_device, register_entity, ) -from tests.common import async_fire_time_changed +from tests.common import async_fire_time_changed, mock_restore_cache RELAY_BLOCK_ID = 0 LIGHT_BLOCK_ID = 2 @@ -806,3 +808,93 @@ async def test_rpc_runs_connected_events_when_initialized( # BLE script list is called during connected events assert call.script_list() in mock_rpc_device.mock_calls + + +async def test_block_sleeping_device_connection_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_block_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test block sleeping device connection error during initialize.""" + sleep_period = 1000 + entry = await init_integration(hass, 1, sleep_period=sleep_period, skip_setup=True) + register_device(device_reg, entry) + entity_id = register_entity( + hass, BINARY_SENSOR_DOMAIN, "test_name_motion", "sensor_0-motion", entry + ) + mock_restore_cache(hass, [State(entity_id, STATE_ON)]) + monkeypatch.setattr(mock_block_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_ON + + # Make device online event with connection error + monkeypatch.setattr( + mock_block_device, + "initialize", + AsyncMock( + side_effect=DeviceConnectionError, + ), + ) + mock_block_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error connecting to Shelly device" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_ON + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=sleep_period * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Sleeping device did not update" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE + + +async def test_rpc_sleeping_device_connection_error( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_rpc_device: Mock, + device_reg: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test RPC sleeping device connection error during initialize.""" + sleep_period = 1000 + entry = await init_integration(hass, 2, sleep_period=1000, skip_setup=True) + register_device(device_reg, entry) + entity_id = register_entity( + hass, BINARY_SENSOR_DOMAIN, "test_name_cloud", "cloud-cloud", entry + ) + mock_restore_cache(hass, [State(entity_id, STATE_ON)]) + monkeypatch.setattr(mock_rpc_device, "initialized", False) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert get_entity_state(hass, entity_id) == STATE_ON + + # Make device online event with connection error + monkeypatch.setattr( + mock_rpc_device, + "initialize", + AsyncMock( + side_effect=DeviceConnectionError, + ), + ) + mock_rpc_device.mock_online() + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Error connecting to Shelly device" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_ON + + # Move time to generate sleep period update + freezer.tick(timedelta(seconds=sleep_period * SLEEP_PERIOD_MULTIPLIER)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + assert "Sleeping device did not update" in caplog.text + assert get_entity_state(hass, entity_id) == STATE_UNAVAILABLE From 6fe20be095db62fb49c7b0f0cad0cb81c7820381 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 09:07:48 -0500 Subject: [PATCH 124/272] Fix usb scan delaying shutdown (#116390) If the integration page is accessed right before shutdown it can trigger the usb scan debouncer which was not marked as background so shutdown would wait for the scan to finish --- homeassistant/components/usb/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index 959a8f5894c..46950ba5b91 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -394,6 +394,7 @@ class USBDiscovery: cooldown=REQUEST_SCAN_COOLDOWN, immediate=True, function=self._async_scan, + background=True, ) await self._request_debouncer.async_call() From 0a9ac6b7a90c7a102c313e5cbf59d2d07d6b9ede Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 29 Apr 2024 14:09:46 +0000 Subject: [PATCH 125/272] Bump version to 2024.5.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 a56405d810a..07f4058ea19 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 fc2f658a9c0..68b38bb516b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b1" +version = "2024.5.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From f1dda8ef63dee9af06d7fb30208583c7c80c1bfe Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 29 Apr 2024 07:15:46 -0700 Subject: [PATCH 126/272] Add Ollama Conversation Agent Entity (#116363) * Add ConversationEntity to OLlama integration * Add assist_pipeline dependencies --- homeassistant/components/ollama/__init__.py | 228 +----------- .../components/ollama/conversation.py | 258 +++++++++++++ homeassistant/components/ollama/manifest.json | 1 + tests/components/ollama/test_conversation.py | 347 ++++++++++++++++++ tests/components/ollama/test_init.py | 340 +---------------- 5 files changed, 617 insertions(+), 557 deletions(-) create mode 100644 homeassistant/components/ollama/conversation.py create mode 100644 tests/components/ollama/test_conversation.py diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index 8c9b00f3c9c..323642a8d90 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -4,40 +4,17 @@ from __future__ import annotations import asyncio import logging -import time -from typing import Literal import httpx import ollama -from homeassistant.components import conversation -from homeassistant.components.homeassistant.exposed_entities import async_should_expose from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_URL, MATCH_ALL +from homeassistant.const import CONF_URL, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady, TemplateError -from homeassistant.helpers import ( - area_registry as ar, - config_validation as cv, - device_registry as dr, - entity_registry as er, - intent, - template, -) -from homeassistant.util import ulid +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv -from .const import ( - CONF_MAX_HISTORY, - CONF_MODEL, - CONF_PROMPT, - DEFAULT_MAX_HISTORY, - DEFAULT_PROMPT, - DEFAULT_TIMEOUT, - DOMAIN, - KEEP_ALIVE_FOREVER, - MAX_HISTORY_SECONDS, -) -from .models import ExposedEntity, MessageHistory, MessageRole +from .const import CONF_MAX_HISTORY, CONF_MODEL, CONF_PROMPT, DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -46,11 +23,11 @@ __all__ = [ "CONF_PROMPT", "CONF_MODEL", "CONF_MAX_HISTORY", - "MAX_HISTORY_NO_LIMIT", "DOMAIN", ] CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +PLATFORMS = (Platform.CONVERSATION,) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -65,202 +42,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = client - conversation.async_set_agent(hass, entry, OllamaAgent(hass, entry)) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Ollama.""" + if not await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + return False hass.data[DOMAIN].pop(entry.entry_id) - conversation.async_unset_agent(hass, entry) return True - - -class OllamaAgent(conversation.AbstractConversationAgent): - """Ollama conversation agent.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the agent.""" - self.hass = hass - self.entry = entry - - # conversation id -> message history - self._history: dict[str, MessageHistory] = {} - - @property - def supported_languages(self) -> list[str] | Literal["*"]: - """Return a list of supported languages.""" - return MATCH_ALL - - async def async_process( - self, user_input: conversation.ConversationInput - ) -> conversation.ConversationResult: - """Process a sentence.""" - settings = {**self.entry.data, **self.entry.options} - - client = self.hass.data[DOMAIN][self.entry.entry_id] - conversation_id = user_input.conversation_id or ulid.ulid_now() - model = settings[CONF_MODEL] - - # Look up message history - message_history: MessageHistory | None = None - message_history = self._history.get(conversation_id) - if message_history is None: - # New history - # - # Render prompt and error out early if there's a problem - raw_prompt = settings.get(CONF_PROMPT, DEFAULT_PROMPT) - try: - prompt = self._generate_prompt(raw_prompt) - _LOGGER.debug("Prompt: %s", prompt) - except TemplateError as err: - _LOGGER.error("Error rendering prompt: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem generating my prompt: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - message_history = MessageHistory( - timestamp=time.monotonic(), - messages=[ - ollama.Message(role=MessageRole.SYSTEM.value, content=prompt) - ], - ) - self._history[conversation_id] = message_history - else: - # Bump timestamp so this conversation won't get cleaned up - message_history.timestamp = time.monotonic() - - # Clean up old histories - self._prune_old_histories() - - # Trim this message history to keep a maximum number of *user* messages - max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) - self._trim_history(message_history, max_messages) - - # Add new user message - message_history.messages.append( - ollama.Message(role=MessageRole.USER.value, content=user_input.text) - ) - - # Get response - try: - response = await client.chat( - model=model, - # Make a copy of the messages because we mutate the list later - messages=list(message_history.messages), - stream=False, - keep_alive=KEEP_ALIVE_FOREVER, - ) - except (ollama.RequestError, ollama.ResponseError) as err: - _LOGGER.error("Unexpected error talking to Ollama server: %s", err) - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_error( - intent.IntentResponseErrorCode.UNKNOWN, - f"Sorry, I had a problem talking to the Ollama server: {err}", - ) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - response_message = response["message"] - message_history.messages.append( - ollama.Message( - role=response_message["role"], content=response_message["content"] - ) - ) - - # Create intent response - intent_response = intent.IntentResponse(language=user_input.language) - intent_response.async_set_speech(response_message["content"]) - return conversation.ConversationResult( - response=intent_response, conversation_id=conversation_id - ) - - def _prune_old_histories(self) -> None: - """Remove old message histories.""" - now = time.monotonic() - self._history = { - conversation_id: message_history - for conversation_id, message_history in self._history.items() - if (now - message_history.timestamp) <= MAX_HISTORY_SECONDS - } - - def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: - """Trims excess messages from a single history.""" - if max_messages < 1: - # Keep all messages - return - - if message_history.num_user_messages >= max_messages: - # Trim history but keep system prompt (first message). - # Every other message should be an assistant message, so keep 2x - # message objects. - num_keep = 2 * max_messages - drop_index = len(message_history.messages) - num_keep - message_history.messages = [ - message_history.messages[0] - ] + message_history.messages[drop_index:] - - def _generate_prompt(self, raw_prompt: str) -> str: - """Generate a prompt for the user.""" - return template.Template(raw_prompt, self.hass).async_render( - { - "ha_name": self.hass.config.location_name, - "ha_language": self.hass.config.language, - "exposed_entities": self._get_exposed_entities(), - }, - parse_result=False, - ) - - def _get_exposed_entities(self) -> list[ExposedEntity]: - """Get state list of exposed entities.""" - area_registry = ar.async_get(self.hass) - entity_registry = er.async_get(self.hass) - device_registry = dr.async_get(self.hass) - - exposed_entities = [] - exposed_states = [ - state - for state in self.hass.states.async_all() - if async_should_expose(self.hass, conversation.DOMAIN, state.entity_id) - ] - - for state in exposed_states: - entity = entity_registry.async_get(state.entity_id) - names = [state.name] - area_names = [] - - if entity is not None: - # Add aliases - names.extend(entity.aliases) - if entity.area_id and ( - area := area_registry.async_get_area(entity.area_id) - ): - # Entity is in area - area_names.append(area.name) - area_names.extend(area.aliases) - elif entity.device_id and ( - device := device_registry.async_get(entity.device_id) - ): - # Check device area - if device.area_id and ( - area := area_registry.async_get_area(device.area_id) - ): - area_names.append(area.name) - area_names.extend(area.aliases) - - exposed_entities.append( - ExposedEntity( - entity_id=state.entity_id, - state=state, - names=names, - area_names=area_names, - ) - ) - - return exposed_entities diff --git a/homeassistant/components/ollama/conversation.py b/homeassistant/components/ollama/conversation.py new file mode 100644 index 00000000000..8a5f6e7d5c5 --- /dev/null +++ b/homeassistant/components/ollama/conversation.py @@ -0,0 +1,258 @@ +"""The conversation platform for the Ollama integration.""" + +from __future__ import annotations + +import logging +import time +from typing import Literal + +import ollama + +from homeassistant.components import assist_pipeline, conversation +from homeassistant.components.homeassistant.exposed_entities import async_should_expose +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + intent, + template, +) +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import ulid + +from .const import ( + CONF_MAX_HISTORY, + CONF_MODEL, + CONF_PROMPT, + DEFAULT_MAX_HISTORY, + DEFAULT_PROMPT, + DOMAIN, + KEEP_ALIVE_FOREVER, + MAX_HISTORY_SECONDS, +) +from .models import ExposedEntity, MessageHistory, MessageRole + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up conversation entities.""" + agent = OllamaConversationEntity(hass, config_entry) + async_add_entities([agent]) + + +class OllamaConversationEntity( + conversation.ConversationEntity, conversation.AbstractConversationAgent +): + """Ollama conversation agent.""" + + _attr_has_entity_name = True + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.hass = hass + self.entry = entry + + # conversation id -> message history + self._history: dict[str, MessageHistory] = {} + self._attr_name = entry.title + self._attr_unique_id = entry.entry_id + + async def async_added_to_hass(self) -> None: + """When entity is added to Home Assistant.""" + await super().async_added_to_hass() + assist_pipeline.async_migrate_engine( + self.hass, "conversation", self.entry.entry_id, self.entity_id + ) + conversation.async_set_agent(self.hass, self.entry, self) + + async def async_will_remove_from_hass(self) -> None: + """When entity will be removed from Home Assistant.""" + conversation.async_unset_agent(self.hass, self.entry) + await super().async_will_remove_from_hass() + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def async_process( + self, user_input: conversation.ConversationInput + ) -> conversation.ConversationResult: + """Process a sentence.""" + settings = {**self.entry.data, **self.entry.options} + + client = self.hass.data[DOMAIN][self.entry.entry_id] + conversation_id = user_input.conversation_id or ulid.ulid_now() + model = settings[CONF_MODEL] + + # Look up message history + message_history: MessageHistory | None = None + message_history = self._history.get(conversation_id) + if message_history is None: + # New history + # + # Render prompt and error out early if there's a problem + raw_prompt = settings.get(CONF_PROMPT, DEFAULT_PROMPT) + try: + prompt = self._generate_prompt(raw_prompt) + _LOGGER.debug("Prompt: %s", prompt) + except TemplateError as err: + _LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem generating my prompt: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + message_history = MessageHistory( + timestamp=time.monotonic(), + messages=[ + ollama.Message(role=MessageRole.SYSTEM.value, content=prompt) + ], + ) + self._history[conversation_id] = message_history + else: + # Bump timestamp so this conversation won't get cleaned up + message_history.timestamp = time.monotonic() + + # Clean up old histories + self._prune_old_histories() + + # Trim this message history to keep a maximum number of *user* messages + max_messages = int(settings.get(CONF_MAX_HISTORY, DEFAULT_MAX_HISTORY)) + self._trim_history(message_history, max_messages) + + # Add new user message + message_history.messages.append( + ollama.Message(role=MessageRole.USER.value, content=user_input.text) + ) + + # Get response + try: + response = await client.chat( + model=model, + # Make a copy of the messages because we mutate the list later + messages=list(message_history.messages), + stream=False, + keep_alive=KEEP_ALIVE_FOREVER, + ) + except (ollama.RequestError, ollama.ResponseError) as err: + _LOGGER.error("Unexpected error talking to Ollama server: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to the Ollama server: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + response_message = response["message"] + message_history.messages.append( + ollama.Message( + role=response_message["role"], content=response_message["content"] + ) + ) + + # Create intent response + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(response_message["content"]) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + def _prune_old_histories(self) -> None: + """Remove old message histories.""" + now = time.monotonic() + self._history = { + conversation_id: message_history + for conversation_id, message_history in self._history.items() + if (now - message_history.timestamp) <= MAX_HISTORY_SECONDS + } + + def _trim_history(self, message_history: MessageHistory, max_messages: int) -> None: + """Trims excess messages from a single history.""" + if max_messages < 1: + # Keep all messages + return + + if message_history.num_user_messages >= max_messages: + # Trim history but keep system prompt (first message). + # Every other message should be an assistant message, so keep 2x + # message objects. + num_keep = 2 * max_messages + drop_index = len(message_history.messages) - num_keep + message_history.messages = [ + message_history.messages[0] + ] + message_history.messages[drop_index:] + + def _generate_prompt(self, raw_prompt: str) -> str: + """Generate a prompt for the user.""" + return template.Template(raw_prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + "ha_language": self.hass.config.language, + "exposed_entities": self._get_exposed_entities(), + }, + parse_result=False, + ) + + def _get_exposed_entities(self) -> list[ExposedEntity]: + """Get state list of exposed entities.""" + area_registry = ar.async_get(self.hass) + entity_registry = er.async_get(self.hass) + device_registry = dr.async_get(self.hass) + + exposed_entities = [] + exposed_states = [ + state + for state in self.hass.states.async_all() + if async_should_expose(self.hass, conversation.DOMAIN, state.entity_id) + ] + + for state in exposed_states: + entity = entity_registry.async_get(state.entity_id) + names = [state.name] + area_names = [] + + if entity is not None: + # Add aliases + names.extend(entity.aliases) + if entity.area_id and ( + area := area_registry.async_get_area(entity.area_id) + ): + # Entity is in area + area_names.append(area.name) + area_names.extend(area.aliases) + elif entity.device_id and ( + device := device_registry.async_get(entity.device_id) + ): + # Check device area + if device.area_id and ( + area := area_registry.async_get_area(device.area_id) + ): + area_names.append(area.name) + area_names.extend(area.aliases) + + exposed_entities.append( + ExposedEntity( + entity_id=state.entity_id, + state=state, + names=names, + area_names=area_names, + ) + ) + + return exposed_entities diff --git a/homeassistant/components/ollama/manifest.json b/homeassistant/components/ollama/manifest.json index 6b16ae667f1..7afaaa3dbd4 100644 --- a/homeassistant/components/ollama/manifest.json +++ b/homeassistant/components/ollama/manifest.json @@ -1,6 +1,7 @@ { "domain": "ollama", "name": "Ollama", + "after_dependencies": ["assist_pipeline"], "codeowners": ["@synesthesiam"], "config_flow": true, "dependencies": ["conversation"], diff --git a/tests/components/ollama/test_conversation.py b/tests/components/ollama/test_conversation.py new file mode 100644 index 00000000000..080d0d34f2d --- /dev/null +++ b/tests/components/ollama/test_conversation.py @@ -0,0 +1,347 @@ +"""Tests for the Ollama integration.""" + +from unittest.mock import AsyncMock, patch + +from ollama import Message, ResponseError +import pytest + +from homeassistant.components import conversation, ollama +from homeassistant.components.homeassistant.exposed_entities import async_expose_entity +from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import ( + area_registry as ar, + device_registry as dr, + entity_registry as er, + intent, +) + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("agent_id", [None, "conversation.mock_title"]) +async def test_chat( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + agent_id: str, +) -> None: + """Test that the chat function is called with the appropriate arguments.""" + + if agent_id is None: + agent_id = mock_config_entry.entry_id + + # Create some areas, devices, and entities + area_kitchen = area_registry.async_get_or_create("kitchen_id") + area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") + area_bedroom = area_registry.async_get_or_create("bedroom_id") + area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") + area_office = area_registry.async_get_or_create("office_id") + area_office = area_registry.async_update(area_office.id, name="office") + + entry = MockConfigEntry() + entry.add_to_hass(hass) + kitchen_device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections=set(), + identifiers={("demo", "id-1234")}, + ) + device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) + + kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") + kitchen_light = entity_registry.async_update_entity( + kitchen_light.entity_id, device_id=kitchen_device.id + ) + hass.states.async_set( + kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} + ) + + bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") + bedroom_light = entity_registry.async_update_entity( + bedroom_light.entity_id, area_id=area_bedroom.id + ) + hass.states.async_set( + bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} + ) + + # Hide the office light + office_light = entity_registry.async_get_or_create("light", "demo", "ABCD") + office_light = entity_registry.async_update_entity( + office_light.entity_id, area_id=area_office.id + ) + hass.states.async_set( + office_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "office light"} + ) + async_expose_entity(hass, conversation.DOMAIN, office_light.entity_id, False) + + with patch( + "ollama.AsyncClient.chat", + return_value={"message": {"role": "assistant", "content": "test response"}}, + ) as mock_chat: + result = await conversation.async_converse( + hass, + "test message", + None, + Context(), + agent_id=agent_id, + ) + + assert mock_chat.call_count == 1 + args = mock_chat.call_args.kwargs + prompt = args["messages"][0]["content"] + + assert args["model"] == "test model" + assert args["messages"] == [ + Message({"role": "system", "content": prompt}), + Message({"role": "user", "content": "test message"}), + ] + + # Verify only exposed devices/areas are in prompt + assert "kitchen light" in prompt + assert "bedroom light" in prompt + assert "office light" not in prompt + assert "office" not in prompt + + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert result.response.speech["plain"]["speech"] == "test response" + + +async def test_message_history_trimming( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test that a single message history is trimmed according to the config.""" + response_idx = 0 + + def response(*args, **kwargs) -> dict: + nonlocal response_idx + response_idx += 1 + return {"message": {"role": "assistant", "content": f"response {response_idx}"}} + + with patch( + "ollama.AsyncClient.chat", + side_effect=response, + ) as mock_chat: + # mock_init_component sets "max_history" to 2 + for i in range(5): + result = await conversation.async_converse( + hass, + f"message {i+1}", + conversation_id="1234", + context=Context(), + agent_id=mock_config_entry.entry_id, + ) + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + + assert mock_chat.call_count == 5 + args = mock_chat.call_args_list + prompt = args[0].kwargs["messages"][0]["content"] + + # system + user-1 + assert len(args[0].kwargs["messages"]) == 2 + assert args[0].kwargs["messages"][1]["content"] == "message 1" + + # Full history + # system + user-1 + assistant-1 + user-2 + assert len(args[1].kwargs["messages"]) == 4 + assert args[1].kwargs["messages"][0]["role"] == "system" + assert args[1].kwargs["messages"][0]["content"] == prompt + assert args[1].kwargs["messages"][1]["role"] == "user" + assert args[1].kwargs["messages"][1]["content"] == "message 1" + assert args[1].kwargs["messages"][2]["role"] == "assistant" + assert args[1].kwargs["messages"][2]["content"] == "response 1" + assert args[1].kwargs["messages"][3]["role"] == "user" + assert args[1].kwargs["messages"][3]["content"] == "message 2" + + # Full history + # system + user-1 + assistant-1 + user-2 + assistant-2 + user-3 + assert len(args[2].kwargs["messages"]) == 6 + assert args[2].kwargs["messages"][0]["role"] == "system" + assert args[2].kwargs["messages"][0]["content"] == prompt + assert args[2].kwargs["messages"][1]["role"] == "user" + assert args[2].kwargs["messages"][1]["content"] == "message 1" + assert args[2].kwargs["messages"][2]["role"] == "assistant" + assert args[2].kwargs["messages"][2]["content"] == "response 1" + assert args[2].kwargs["messages"][3]["role"] == "user" + assert args[2].kwargs["messages"][3]["content"] == "message 2" + assert args[2].kwargs["messages"][4]["role"] == "assistant" + assert args[2].kwargs["messages"][4]["content"] == "response 2" + assert args[2].kwargs["messages"][5]["role"] == "user" + assert args[2].kwargs["messages"][5]["content"] == "message 3" + + # Trimmed down to two user messages. + # system + user-2 + assistant-2 + user-3 + assistant-3 + user-4 + assert len(args[3].kwargs["messages"]) == 6 + assert args[3].kwargs["messages"][0]["role"] == "system" + assert args[3].kwargs["messages"][0]["content"] == prompt + assert args[3].kwargs["messages"][1]["role"] == "user" + assert args[3].kwargs["messages"][1]["content"] == "message 2" + assert args[3].kwargs["messages"][2]["role"] == "assistant" + assert args[3].kwargs["messages"][2]["content"] == "response 2" + assert args[3].kwargs["messages"][3]["role"] == "user" + assert args[3].kwargs["messages"][3]["content"] == "message 3" + assert args[3].kwargs["messages"][4]["role"] == "assistant" + assert args[3].kwargs["messages"][4]["content"] == "response 3" + assert args[3].kwargs["messages"][5]["role"] == "user" + assert args[3].kwargs["messages"][5]["content"] == "message 4" + + # Trimmed down to two user messages. + # system + user-3 + assistant-3 + user-4 + assistant-4 + user-5 + assert len(args[3].kwargs["messages"]) == 6 + assert args[4].kwargs["messages"][0]["role"] == "system" + assert args[4].kwargs["messages"][0]["content"] == prompt + assert args[4].kwargs["messages"][1]["role"] == "user" + assert args[4].kwargs["messages"][1]["content"] == "message 3" + assert args[4].kwargs["messages"][2]["role"] == "assistant" + assert args[4].kwargs["messages"][2]["content"] == "response 3" + assert args[4].kwargs["messages"][3]["role"] == "user" + assert args[4].kwargs["messages"][3]["content"] == "message 4" + assert args[4].kwargs["messages"][4]["role"] == "assistant" + assert args[4].kwargs["messages"][4]["content"] == "response 4" + assert args[4].kwargs["messages"][5]["role"] == "user" + assert args[4].kwargs["messages"][5]["content"] == "message 5" + + +async def test_message_history_pruning( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test that old message histories are pruned.""" + with patch( + "ollama.AsyncClient.chat", + return_value={"message": {"role": "assistant", "content": "test response"}}, + ): + # Create 3 different message histories + conversation_ids: list[str] = [] + for i in range(3): + result = await conversation.async_converse( + hass, + f"message {i+1}", + conversation_id=None, + context=Context(), + agent_id=mock_config_entry.entry_id, + ) + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + assert isinstance(result.conversation_id, str) + conversation_ids.append(result.conversation_id) + + agent = conversation.get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert len(agent._history) == 3 + assert agent._history.keys() == set(conversation_ids) + + # Modify the timestamps of the first 2 histories so they will be pruned + # on the next cycle. + for conversation_id in conversation_ids[:2]: + # Move back 2 hours + agent._history[conversation_id].timestamp -= 2 * 60 * 60 + + # Next cycle + result = await conversation.async_converse( + hass, + "test message", + conversation_id=None, + context=Context(), + agent_id=mock_config_entry.entry_id, + ) + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + + # Only the most recent histories should remain + assert len(agent._history) == 2 + assert conversation_ids[-1] in agent._history + assert result.conversation_id in agent._history + + +async def test_message_history_unlimited( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test that message history is not trimmed when max_history = 0.""" + conversation_id = "1234" + with ( + patch( + "ollama.AsyncClient.chat", + return_value={"message": {"role": "assistant", "content": "test response"}}, + ), + patch.object(mock_config_entry, "options", {ollama.CONF_MAX_HISTORY: 0}), + ): + for i in range(100): + result = await conversation.async_converse( + hass, + f"message {i+1}", + conversation_id=conversation_id, + context=Context(), + agent_id=mock_config_entry.entry_id, + ) + assert ( + result.response.response_type == intent.IntentResponseType.ACTION_DONE + ), result + + agent = conversation.get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + + assert len(agent._history) == 1 + assert conversation_id in agent._history + assert agent._history[conversation_id].num_user_messages == 100 + + +async def test_error_handling( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component +) -> None: + """Test error handling during converse.""" + with patch( + "ollama.AsyncClient.chat", + new_callable=AsyncMock, + side_effect=ResponseError("test error"), + ): + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_template_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template error handling works.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", + }, + ) + with patch( + "ollama.AsyncClient.list", + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse( + hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id + ) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_conversation_agent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test OllamaConversationEntity.""" + agent = conversation.get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert agent.supported_languages == MATCH_ALL diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 5326a8ed609..c296d6de700 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1,351 +1,17 @@ """Tests for the Ollama integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from httpx import ConnectError -from ollama import Message, ResponseError import pytest -from homeassistant.components import conversation, ollama -from homeassistant.components.homeassistant.exposed_entities import async_expose_entity -from homeassistant.const import ATTR_FRIENDLY_NAME, MATCH_ALL -from homeassistant.core import Context, HomeAssistant -from homeassistant.helpers import ( - area_registry as ar, - device_registry as dr, - entity_registry as er, - intent, -) +from homeassistant.components import ollama +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_chat( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, - area_registry: ar.AreaRegistry, - device_registry: dr.DeviceRegistry, - entity_registry: er.EntityRegistry, -) -> None: - """Test that the chat function is called with the appropriate arguments.""" - - # Create some areas, devices, and entities - area_kitchen = area_registry.async_get_or_create("kitchen_id") - area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen") - area_bedroom = area_registry.async_get_or_create("bedroom_id") - area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom") - area_office = area_registry.async_get_or_create("office_id") - area_office = area_registry.async_update(area_office.id, name="office") - - entry = MockConfigEntry() - entry.add_to_hass(hass) - kitchen_device = device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections=set(), - identifiers={("demo", "id-1234")}, - ) - device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) - - kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") - kitchen_light = entity_registry.async_update_entity( - kitchen_light.entity_id, device_id=kitchen_device.id - ) - hass.states.async_set( - kitchen_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "kitchen light"} - ) - - bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") - bedroom_light = entity_registry.async_update_entity( - bedroom_light.entity_id, area_id=area_bedroom.id - ) - hass.states.async_set( - bedroom_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "bedroom light"} - ) - - # Hide the office light - office_light = entity_registry.async_get_or_create("light", "demo", "ABCD") - office_light = entity_registry.async_update_entity( - office_light.entity_id, area_id=area_office.id - ) - hass.states.async_set( - office_light.entity_id, "on", attributes={ATTR_FRIENDLY_NAME: "office light"} - ) - async_expose_entity(hass, conversation.DOMAIN, office_light.entity_id, False) - - with patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ) as mock_chat: - result = await conversation.async_converse( - hass, - "test message", - None, - Context(), - agent_id=mock_config_entry.entry_id, - ) - - assert mock_chat.call_count == 1 - args = mock_chat.call_args.kwargs - prompt = args["messages"][0]["content"] - - assert args["model"] == "test model" - assert args["messages"] == [ - Message({"role": "system", "content": prompt}), - Message({"role": "user", "content": "test message"}), - ] - - # Verify only exposed devices/areas are in prompt - assert "kitchen light" in prompt - assert "bedroom light" in prompt - assert "office light" not in prompt - assert "office" not in prompt - - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert result.response.speech["plain"]["speech"] == "test response" - - -async def test_message_history_trimming( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that a single message history is trimmed according to the config.""" - response_idx = 0 - - def response(*args, **kwargs) -> dict: - nonlocal response_idx - response_idx += 1 - return {"message": {"role": "assistant", "content": f"response {response_idx}"}} - - with patch( - "ollama.AsyncClient.chat", - side_effect=response, - ) as mock_chat: - # mock_init_component sets "max_history" to 2 - for i in range(5): - result = await conversation.async_converse( - hass, - f"message {i+1}", - conversation_id="1234", - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - - assert mock_chat.call_count == 5 - args = mock_chat.call_args_list - prompt = args[0].kwargs["messages"][0]["content"] - - # system + user-1 - assert len(args[0].kwargs["messages"]) == 2 - assert args[0].kwargs["messages"][1]["content"] == "message 1" - - # Full history - # system + user-1 + assistant-1 + user-2 - assert len(args[1].kwargs["messages"]) == 4 - assert args[1].kwargs["messages"][0]["role"] == "system" - assert args[1].kwargs["messages"][0]["content"] == prompt - assert args[1].kwargs["messages"][1]["role"] == "user" - assert args[1].kwargs["messages"][1]["content"] == "message 1" - assert args[1].kwargs["messages"][2]["role"] == "assistant" - assert args[1].kwargs["messages"][2]["content"] == "response 1" - assert args[1].kwargs["messages"][3]["role"] == "user" - assert args[1].kwargs["messages"][3]["content"] == "message 2" - - # Full history - # system + user-1 + assistant-1 + user-2 + assistant-2 + user-3 - assert len(args[2].kwargs["messages"]) == 6 - assert args[2].kwargs["messages"][0]["role"] == "system" - assert args[2].kwargs["messages"][0]["content"] == prompt - assert args[2].kwargs["messages"][1]["role"] == "user" - assert args[2].kwargs["messages"][1]["content"] == "message 1" - assert args[2].kwargs["messages"][2]["role"] == "assistant" - assert args[2].kwargs["messages"][2]["content"] == "response 1" - assert args[2].kwargs["messages"][3]["role"] == "user" - assert args[2].kwargs["messages"][3]["content"] == "message 2" - assert args[2].kwargs["messages"][4]["role"] == "assistant" - assert args[2].kwargs["messages"][4]["content"] == "response 2" - assert args[2].kwargs["messages"][5]["role"] == "user" - assert args[2].kwargs["messages"][5]["content"] == "message 3" - - # Trimmed down to two user messages. - # system + user-2 + assistant-2 + user-3 + assistant-3 + user-4 - assert len(args[3].kwargs["messages"]) == 6 - assert args[3].kwargs["messages"][0]["role"] == "system" - assert args[3].kwargs["messages"][0]["content"] == prompt - assert args[3].kwargs["messages"][1]["role"] == "user" - assert args[3].kwargs["messages"][1]["content"] == "message 2" - assert args[3].kwargs["messages"][2]["role"] == "assistant" - assert args[3].kwargs["messages"][2]["content"] == "response 2" - assert args[3].kwargs["messages"][3]["role"] == "user" - assert args[3].kwargs["messages"][3]["content"] == "message 3" - assert args[3].kwargs["messages"][4]["role"] == "assistant" - assert args[3].kwargs["messages"][4]["content"] == "response 3" - assert args[3].kwargs["messages"][5]["role"] == "user" - assert args[3].kwargs["messages"][5]["content"] == "message 4" - - # Trimmed down to two user messages. - # system + user-3 + assistant-3 + user-4 + assistant-4 + user-5 - assert len(args[3].kwargs["messages"]) == 6 - assert args[4].kwargs["messages"][0]["role"] == "system" - assert args[4].kwargs["messages"][0]["content"] == prompt - assert args[4].kwargs["messages"][1]["role"] == "user" - assert args[4].kwargs["messages"][1]["content"] == "message 3" - assert args[4].kwargs["messages"][2]["role"] == "assistant" - assert args[4].kwargs["messages"][2]["content"] == "response 3" - assert args[4].kwargs["messages"][3]["role"] == "user" - assert args[4].kwargs["messages"][3]["content"] == "message 4" - assert args[4].kwargs["messages"][4]["role"] == "assistant" - assert args[4].kwargs["messages"][4]["content"] == "response 4" - assert args[4].kwargs["messages"][5]["role"] == "user" - assert args[4].kwargs["messages"][5]["content"] == "message 5" - - -async def test_message_history_pruning( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that old message histories are pruned.""" - with patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ): - # Create 3 different message histories - conversation_ids: list[str] = [] - for i in range(3): - result = await conversation.async_converse( - hass, - f"message {i+1}", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - assert isinstance(result.conversation_id, str) - conversation_ids.append(result.conversation_id) - - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert isinstance(agent, ollama.OllamaAgent) - assert len(agent._history) == 3 - assert agent._history.keys() == set(conversation_ids) - - # Modify the timestamps of the first 2 histories so they will be pruned - # on the next cycle. - for conversation_id in conversation_ids[:2]: - # Move back 2 hours - agent._history[conversation_id].timestamp -= 2 * 60 * 60 - - # Next cycle - result = await conversation.async_converse( - hass, - "test message", - conversation_id=None, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - - # Only the most recent histories should remain - assert len(agent._history) == 2 - assert conversation_ids[-1] in agent._history - assert result.conversation_id in agent._history - - -async def test_message_history_unlimited( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test that message history is not trimmed when max_history = 0.""" - conversation_id = "1234" - with ( - patch( - "ollama.AsyncClient.chat", - return_value={"message": {"role": "assistant", "content": "test response"}}, - ), - patch.object(mock_config_entry, "options", {ollama.CONF_MAX_HISTORY: 0}), - ): - for i in range(100): - result = await conversation.async_converse( - hass, - f"message {i+1}", - conversation_id=conversation_id, - context=Context(), - agent_id=mock_config_entry.entry_id, - ) - assert ( - result.response.response_type == intent.IntentResponseType.ACTION_DONE - ), result - - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert isinstance(agent, ollama.OllamaAgent) - - assert len(agent._history) == 1 - assert conversation_id in agent._history - assert agent._history[conversation_id].num_user_messages == 100 - - -async def test_error_handling( - hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component -) -> None: - """Test error handling during converse.""" - with patch( - "ollama.AsyncClient.chat", - new_callable=AsyncMock, - side_effect=ResponseError("test error"), - ): - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_template_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test that template error handling works.""" - hass.config_entries.async_update_entry( - mock_config_entry, - options={ - "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", - }, - ) - with patch( - "ollama.AsyncClient.list", - ): - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - result = await conversation.async_converse( - hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id - ) - - assert result.response.response_type == intent.IntentResponseType.ERROR, result - assert result.response.error_code == "unknown", result - - -async def test_conversation_agent( - hass: HomeAssistant, - mock_config_entry: MockConfigEntry, - mock_init_component, -) -> None: - """Test OllamaAgent.""" - agent = conversation.get_agent_manager(hass).async_get_agent( - mock_config_entry.entry_id - ) - assert agent.supported_languages == MATCH_ALL - - @pytest.mark.parametrize( ("side_effect", "error"), [ From f5b4637da80e9407f9848902dbaa018cf0b4fd65 Mon Sep 17 00:00:00 2001 From: mkmer Date: Mon, 29 Apr 2024 10:16:46 -0400 Subject: [PATCH 127/272] Address late review in Honeywell (#115702) Pass honeywell_data --- homeassistant/components/honeywell/switch.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/honeywell/switch.py b/homeassistant/components/honeywell/switch.py index 4aebde76727..53a9b27ee72 100644 --- a/homeassistant/components/honeywell/switch.py +++ b/homeassistant/components/honeywell/switch.py @@ -40,7 +40,7 @@ async def async_setup_entry( """Set up the Honeywell switches.""" data: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - HoneywellSwitch(hass, config_entry, device, description) + HoneywellSwitch(data, device, description) for device in data.devices.values() if device.raw_ui_data.get("SwitchEmergencyHeatAllowed") for description in SWITCH_TYPES @@ -54,13 +54,12 @@ class HoneywellSwitch(SwitchEntity): def __init__( self, - hass: HomeAssistant, - config_entry: ConfigEntry, + honeywell_data: HoneywellData, device: SomeComfortDevice, description: SwitchEntityDescription, ) -> None: """Initialize the switch.""" - self._data = hass.data[DOMAIN][config_entry.entry_id] + self._data = honeywell_data self._device = device self.entity_description = description self._attr_unique_id = f"{device.deviceid}_{description.key}" From eec1dafe11c16bdd70e9f07f3a60d5f400050655 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Apr 2024 16:58:02 +0200 Subject: [PATCH 128/272] Fix typo in Switchbot cloud (#116388) --- homeassistant/components/switchbot_cloud/climate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/switchbot_cloud/climate.py b/homeassistant/components/switchbot_cloud/climate.py index d184063939a..e04145933ae 100644 --- a/homeassistant/components/switchbot_cloud/climate.py +++ b/homeassistant/components/switchbot_cloud/climate.py @@ -47,13 +47,13 @@ async def async_setup_entry( """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] async_add_entities( - SwitchBotCloudAirConditionner(data.api, device, coordinator) + SwitchBotCloudAirConditioner(data.api, device, coordinator) for device, coordinator in data.devices.climates ) -class SwitchBotCloudAirConditionner(SwitchBotCloudEntity, ClimateEntity): - """Representation of a SwitchBot air conditionner. +class SwitchBotCloudAirConditioner(SwitchBotCloudEntity, ClimateEntity): + """Representation of a SwitchBot air conditioner. As it is an IR device, we don't know the actual state. """ From 3d750414f140cd308f3ab57cc6cfa85b41f12a4e Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Mon, 29 Apr 2024 17:00:13 +0200 Subject: [PATCH 129/272] Deprecate YAML configuration of Habitica (#116374) Add deprecation issue for yaml import --- .../components/habitica/config_flow.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 9ee2aef40ba..9a8852b731d 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -10,9 +10,10 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_API_USER, DEFAULT_URL, DOMAIN @@ -79,6 +80,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data): """Import habitica config from configuration.yaml.""" + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.11.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Habitica", + }, + ) return await self.async_step_user(import_data) From f1e5bbcbcaa029d01a0d7ceb47548423090ff5ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 10:01:15 -0500 Subject: [PATCH 130/272] Fix grammar in internal function comments (#116387) https://github.com/home-assistant/core/pull/116339#discussion_r1582610474 --- homeassistant/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index fe16640a572..73d0e82fa83 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -815,7 +815,7 @@ class HomeAssistant: This method is intended to only be used by core internally and should not be considered a stable API. We will make - breaking change to this function in the future and it + breaking changes to this function in the future and it should not be used in integrations. This method must be run in the event loop. If you are using this in your @@ -1511,7 +1511,7 @@ class EventBus: This method is intended to only be used by core internally and should not be considered a stable API. We will make - breaking change to this function in the future and it + breaking changes to this function in the future and it should not be used in integrations. This method must be run in the event loop. From 8bfcaf3524fe4668c89d331d2ee49ceeccfc2098 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 29 Apr 2024 10:03:35 -0500 Subject: [PATCH 131/272] Add service to log all the current asyncio Tasks to the profiler (#116389) * Add service to log all the current asyncio Tasks to the profiler I have been helping users look for a task leaks, and need a way to examine tasks at run time as trying to get someone to run Home Assistant and attach aiomonitor is too difficult in many cases. * cover --- homeassistant/components/profiler/__init__.py | 45 ++++++++++++++----- homeassistant/components/profiler/icons.json | 1 + .../components/profiler/services.yaml | 1 + .../components/profiler/strings.json | 4 ++ tests/components/profiler/test_init.py | 23 ++++++++++ 5 files changed, 64 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index 30385a1c267..ceb3c3a998b 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -1,6 +1,8 @@ """The profiler integration.""" import asyncio +from collections.abc import Generator +import contextlib from contextlib import suppress from datetime import timedelta from functools import _lru_cache_wrapper @@ -37,6 +39,7 @@ SERVICE_LRU_STATS = "lru_stats" SERVICE_LOG_THREAD_FRAMES = "log_thread_frames" SERVICE_LOG_EVENT_LOOP_SCHEDULED = "log_event_loop_scheduled" SERVICE_SET_ASYNCIO_DEBUG = "set_asyncio_debug" +SERVICE_LOG_CURRENT_TASKS = "log_current_tasks" _LRU_CACHE_WRAPPER_OBJECT = _lru_cache_wrapper.__name__ _SQLALCHEMY_LRU_OBJECT = "LRUCache" @@ -59,6 +62,7 @@ SERVICES = ( SERVICE_LOG_THREAD_FRAMES, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_SET_ASYNCIO_DEBUG, + SERVICE_LOG_CURRENT_TASKS, ) DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) @@ -241,21 +245,20 @@ async def async_setup_entry( # noqa: C901 "".join(traceback.format_stack(frames.get(ident))).strip(), ) + async def _async_dump_current_tasks(call: ServiceCall) -> None: + """Log all current tasks in the event loop.""" + with _increase_repr_limit(): + for task in asyncio.all_tasks(): + if not task.cancelled(): + _LOGGER.critical("Task: %s", _safe_repr(task)) + async def _async_dump_scheduled(call: ServiceCall) -> None: """Log all scheduled in the event loop.""" - arepr = reprlib.aRepr - original_maxstring = arepr.maxstring - original_maxother = arepr.maxother - arepr.maxstring = 300 - arepr.maxother = 300 - handle: asyncio.Handle - try: + with _increase_repr_limit(): + handle: asyncio.Handle for handle in getattr(hass.loop, "_scheduled"): if not handle.cancelled(): _LOGGER.critical("Scheduled: %s", handle) - finally: - arepr.maxstring = original_maxstring - arepr.maxother = original_maxother async def _async_asyncio_debug(call: ServiceCall) -> None: """Enable or disable asyncio debug.""" @@ -372,6 +375,13 @@ async def async_setup_entry( # noqa: C901 schema=vol.Schema({vol.Optional(CONF_ENABLED, default=True): cv.boolean}), ) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_LOG_CURRENT_TASKS, + _async_dump_current_tasks, + ) + return True @@ -573,3 +583,18 @@ def _log_object_sources( _LOGGER.critical("New objects overflowed by %s", new_objects_overflow) elif not had_new_object_growth: _LOGGER.critical("No new object growth found") + + +@contextlib.contextmanager +def _increase_repr_limit() -> Generator[None, None, None]: + """Increase the repr limit.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother diff --git a/homeassistant/components/profiler/icons.json b/homeassistant/components/profiler/icons.json index 9a8c0e85f0d..4dda003c186 100644 --- a/homeassistant/components/profiler/icons.json +++ b/homeassistant/components/profiler/icons.json @@ -8,6 +8,7 @@ "start_log_object_sources": "mdi:play", "stop_log_object_sources": "mdi:stop", "lru_stats": "mdi:chart-areaspline", + "log_current_tasks": "mdi:format-list-bulleted", "log_thread_frames": "mdi:format-list-bulleted", "log_event_loop_scheduled": "mdi:calendar-clock", "set_asyncio_debug": "mdi:bug-check" diff --git a/homeassistant/components/profiler/services.yaml b/homeassistant/components/profiler/services.yaml index 6842b2f45f2..82cdcf8d96e 100644 --- a/homeassistant/components/profiler/services.yaml +++ b/homeassistant/components/profiler/services.yaml @@ -59,3 +59,4 @@ set_asyncio_debug: default: true selector: boolean: +log_current_tasks: diff --git a/homeassistant/components/profiler/strings.json b/homeassistant/components/profiler/strings.json index 980550a1a4a..7a31c567040 100644 --- a/homeassistant/components/profiler/strings.json +++ b/homeassistant/components/profiler/strings.json @@ -93,6 +93,10 @@ "description": "Whether to enable or disable asyncio debug." } } + }, + "log_current_tasks": { + "name": "Log current asyncio tasks", + "description": "Logs all the current asyncio tasks." } } } diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index 3cade465347..ba605049e72 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -18,6 +18,7 @@ from homeassistant.components.profiler import ( CONF_ENABLED, CONF_SECONDS, SERVICE_DUMP_LOG_OBJECTS, + SERVICE_LOG_CURRENT_TASKS, SERVICE_LOG_EVENT_LOOP_SCHEDULED, SERVICE_LOG_THREAD_FRAMES, SERVICE_LRU_STATS, @@ -221,6 +222,28 @@ async def test_log_thread_frames( await hass.async_block_till_done() +async def test_log_current_tasks( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test we can log current tasks.""" + + entry = MockConfigEntry(domain=DOMAIN) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert hass.services.has_service(DOMAIN, SERVICE_LOG_CURRENT_TASKS) + + await hass.services.async_call(DOMAIN, SERVICE_LOG_CURRENT_TASKS, {}, blocking=True) + + assert "test_log_current_tasks" in caplog.text + caplog.clear() + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + async def test_log_scheduled( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: From 8ed10c7c4f9b33f2e1e341d858ed6f488a1cd04f Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:08:36 +0200 Subject: [PATCH 132/272] Bump fyta_cli to 0.4.1 (#115918) * bump fyta_cli to 0.4.0 * Update PLANT_STATUS and add PLANT_MEASUREMENT_STATUS * bump fyta_cli to v0.4.0 * minor adjustments of states to API documentation --- homeassistant/components/fyta/manifest.json | 2 +- homeassistant/components/fyta/sensor.py | 28 +++++++---- homeassistant/components/fyta/strings.json | 53 +++++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 55255777994..020ab330152 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["fyta_cli==0.3.5"] + "requirements": ["fyta_cli==0.4.1"] } diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 2b9e8e3de07..c3e90cef28e 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Final -from fyta_cli.fyta_connector import PLANT_STATUS +from fyta_cli.fyta_connector import PLANT_MEASUREMENT_STATUS, PLANT_STATUS from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,7 +34,15 @@ class FytaSensorEntityDescription(SensorEntityDescription): ) -PLANT_STATUS_LIST: list[str] = ["too_low", "low", "perfect", "high", "too_high"] +PLANT_STATUS_LIST: list[str] = ["deleted", "doing_great", "need_attention", "no_sensor"] +PLANT_MEASUREMENT_STATUS_LIST: list[str] = [ + "no_data", + "too_low", + "low", + "perfect", + "high", + "too_high", +] SENSORS: Final[list[FytaSensorEntityDescription]] = [ FytaSensorEntityDescription( @@ -52,29 +60,29 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ key="temperature_status", translation_key="temperature_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="light_status", translation_key="light_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="moisture_status", translation_key="moisture_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="salinity_status", translation_key="salinity_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="temperature", diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 3df851489bc..bacd24555b0 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -36,6 +36,16 @@ "plant_status": { "name": "Plant state", "state": { + "deleted": "Deleted", + "doing_great": "Doing great", + "need_attention": "Needs attention", + "no_sensor": "No sensor" + } + }, + "temperature_status": { + "name": "Temperature state", + "state": { + "no_data": "No data", "too_low": "Too low", "low": "Low", "perfect": "Perfect", @@ -43,44 +53,37 @@ "too_high": "Too high" } }, - "temperature_status": { - "name": "Temperature state", - "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" - } - }, "light_status": { "name": "Light state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "moisture_status": { "name": "Moisture state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "salinity_status": { "name": "Salinity state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "light": { diff --git a/requirements_all.txt b/requirements_all.txt index 551d6ae4755..cbc4144ef26 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -900,7 +900,7 @@ freesms==0.2.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.5 +fyta_cli==0.4.1 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5faa35b01dc..5b0e2ce3373 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -735,7 +735,7 @@ freebox-api==1.1.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.5 +fyta_cli==0.4.1 # homeassistant.components.google_translate gTTS==2.2.4 From 180e178a692c66add90e98eda6a339ab75b9c529 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:09:07 +0200 Subject: [PATCH 133/272] Store access token in entry for Fyta (#116260) * save access_token and expiration date in ConfigEntry * add MINOR_VERSION and async_migrate_entry * shorten reading of expiration from config entry * add additional consts and test for config entry migration * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/__init__.py Co-authored-by: Joost Lekkerkerker * omit check for datetime data type * Update homeassistant/components/fyta/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fyta/__init__.py | 52 ++++++++++++++++++-- homeassistant/components/fyta/config_flow.py | 17 +++++-- homeassistant/components/fyta/const.py | 1 + homeassistant/components/fyta/coordinator.py | 25 ++++++++-- tests/components/fyta/conftest.py | 26 +++++++++- tests/components/fyta/test_config_flow.py | 36 ++++++++++---- tests/components/fyta/test_init.py | 42 ++++++++++++++++ 7 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 tests/components/fyta/test_init.py diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index febd5b94469..205dd97a42f 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -2,15 +2,23 @@ from __future__ import annotations +from datetime import datetime import logging +from typing import Any +from zoneinfo import ZoneInfo from fyta_cli.fyta_connector import FytaConnector from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_EXPIRATION, DOMAIN from .coordinator import FytaCoordinator _LOGGER = logging.getLogger(__name__) @@ -22,11 +30,16 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Fyta integration.""" + tz: str = hass.config.time_zone username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + access_token: str = entry.data[CONF_ACCESS_TOKEN] + expiration: datetime = datetime.fromisoformat( + entry.data[CONF_EXPIRATION] + ).astimezone(ZoneInfo(tz)) - fyta = FytaConnector(username, password) + fyta = FytaConnector(username, password, access_token, expiration, tz) coordinator = FytaCoordinator(hass, fyta) @@ -47,3 +60,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + fyta = FytaConnector( + config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] + ) + credentials: dict[str, Any] = await fyta.login() + await fyta.client.close() + + new[CONF_ACCESS_TOKEN] = credentials[CONF_ACCESS_TOKEN] + new[CONF_EXPIRATION] = credentials[CONF_EXPIRATION].isoformat() + + hass.config_entries.async_update_entry( + config_entry, data=new, minor_version=2, version=1 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index e11c024ec1f..3d83c099ac3 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN +from .const import CONF_EXPIRATION, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,14 +31,19 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fyta.""" VERSION = 1 - _entry: ConfigEntry | None = None + MINOR_VERSION = 2 + + def __init__(self) -> None: + """Initialize FytaConfigFlow.""" + self.credentials: dict[str, Any] = {} + self._entry: ConfigEntry | None = None async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]: """Reusable Auth Helper.""" fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) try: - await fyta.login() + self.credentials = await fyta.login() except FytaConnectionError: return {"base": "cannot_connect"} except FytaAuthentificationError: @@ -51,6 +56,10 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): finally: await fyta.client.close() + self.credentials[CONF_EXPIRATION] = self.credentials[ + CONF_EXPIRATION + ].isoformat() + return {} async def async_step_user( @@ -62,6 +71,7 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) if not (errors := await self.async_auth(user_input)): + user_input |= self.credentials return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) @@ -85,6 +95,7 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): assert self._entry is not None if user_input and not (errors := await self.async_auth(user_input)): + user_input |= self.credentials return self.async_update_reload_and_abort( self._entry, data={**self._entry.data, **user_input} ) diff --git a/homeassistant/components/fyta/const.py b/homeassistant/components/fyta/const.py index f99735dc6fa..bf4636a713a 100644 --- a/homeassistant/components/fyta/const.py +++ b/homeassistant/components/fyta/const.py @@ -1,3 +1,4 @@ """Const for fyta integration.""" DOMAIN = "fyta" +CONF_EXPIRATION = "expiration" diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 65bd0cb532c..021bddf2cf8 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -12,10 +12,13 @@ from fyta_cli.fyta_exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import CONF_EXPIRATION + _LOGGER = logging.getLogger(__name__) @@ -39,17 +42,33 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]): ) -> dict[int, dict[str, Any]]: """Fetch data from API endpoint.""" - if self.fyta.expiration is None or self.fyta.expiration < datetime.now(): + if ( + self.fyta.expiration is None + or self.fyta.expiration.timestamp() < datetime.now().timestamp() + ): await self.renew_authentication() return await self.fyta.update_all_plants() - async def renew_authentication(self) -> None: + async def renew_authentication(self) -> bool: """Renew access token for FYTA API.""" + credentials: dict[str, Any] = {} try: - await self.fyta.login() + credentials = await self.fyta.login() except FytaConnectionError as ex: raise ConfigEntryNotReady from ex except (FytaAuthentificationError, FytaPasswordError) as ex: raise ConfigEntryAuthFailed from ex + + new_config_entry = {**self.config_entry.data} + new_config_entry[CONF_ACCESS_TOKEN] = credentials[CONF_ACCESS_TOKEN] + new_config_entry[CONF_EXPIRATION] = credentials[CONF_EXPIRATION].isoformat() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=new_config_entry + ) + + _LOGGER.debug("Credentials successfully updated") + + return True diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index efebf9827b9..9250c26926a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.fyta.const import CONF_EXPIRATION +from homeassistant.const import CONF_ACCESS_TOKEN + +from .test_config_flow import ACCESS_TOKEN, EXPIRATION + @pytest.fixture def mock_fyta(): @@ -15,7 +20,26 @@ def mock_fyta(): "homeassistant.components.fyta.config_flow.FytaConnector", return_value=mock_fyta_api, ) as mock_fyta_api: - mock_fyta_api.return_value.login.return_value = {} + mock_fyta_api.return_value.login.return_value = { + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } + yield mock_fyta_api + + +@pytest.fixture +def mock_fyta_init(): + """Build a fixture for the Fyta API that connects successfully and returns one device.""" + + mock_fyta_api = AsyncMock() + with patch( + "homeassistant.components.fyta.FytaConnector", + return_value=mock_fyta_api, + ) as mock_fyta_api: + mock_fyta_api.return_value.login.return_value = { + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } yield mock_fyta_api diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 6aad6295819..69478d04ca0 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -1,5 +1,6 @@ """Test the fyta config flow.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock from fyta_cli.fyta_exceptions import ( @@ -10,8 +11,8 @@ from fyta_cli.fyta_exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components.fyta.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,10 +20,12 @@ from tests.common import MockConfigEntry USERNAME = "fyta_user" PASSWORD = "fyta_pass" +ACCESS_TOKEN = "123xyz" +EXPIRATION = datetime.fromisoformat("2024-12-31T10:00:00").astimezone(UTC) async def test_user_flow( - hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry + hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test we get the form.""" @@ -39,7 +42,12 @@ async def test_user_flow( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USERNAME - assert result2["data"] == {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + assert result2["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: "2024-12-31T10:00:00+00:00", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -57,7 +65,7 @@ async def test_form_exceptions( exception: Exception, error: dict[str, str], mock_fyta: AsyncMock, - mock_setup_entry, + mock_setup_entry: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" @@ -89,6 +97,8 @@ async def test_form_exceptions( assert result["title"] == USERNAME assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" assert len(mock_setup_entry.mock_calls) == 1 @@ -134,14 +144,19 @@ async def test_reauth( exception: Exception, error: dict[str, str], mock_fyta: AsyncMock, - mock_setup_entry, + mock_setup_entry: AsyncMock, ) -> None: """Test reauth-flow works.""" entry = MockConfigEntry( domain=DOMAIN, title=USERNAME, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: "2024-06-30T10:00:00+00:00", + }, ) entry.add_to_hass(hass) @@ -157,7 +172,8 @@ async def test_reauth( # tests with connection error result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) await hass.async_block_till_done() @@ -178,5 +194,5 @@ async def test_reauth( assert result["reason"] == "reauth_successful" assert entry.data[CONF_USERNAME] == "other_username" assert entry.data[CONF_PASSWORD] == "other_password" - - assert len(mock_setup_entry.mock_calls) == 1 + assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" diff --git a/tests/components/fyta/test_init.py b/tests/components/fyta/test_init.py new file mode 100644 index 00000000000..844a818df85 --- /dev/null +++ b/tests/components/fyta/test_init.py @@ -0,0 +1,42 @@ +"""Test the initialization.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .test_config_flow import ACCESS_TOKEN, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +async def test_migrate_config_entry( + hass: HomeAssistant, + mock_fyta_init: AsyncMock, +) -> None: + """Test successful migration of entry data.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + assert entry.version == 1 + assert entry.minor_version == 1 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data[CONF_USERNAME] == USERNAME + assert entry.data[CONF_PASSWORD] == PASSWORD + assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" From 420d6a2d9d57313a0f0de361531a46041efc8c2d Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 29 Apr 2024 12:25:16 -0400 Subject: [PATCH 134/272] Fix jvcprojector command timeout with some projectors (#116392) * Fix projector timeout in pyprojector lib v1.0.10 * Fix projector timeout by increasing time between power command and refresh. * Bump jvcprojector lib to ensure unknown power states are handled --- homeassistant/components/jvc_projector/manifest.json | 2 +- homeassistant/components/jvc_projector/remote.py | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index de7e77197f2..d3e1bf3d940 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.0.9"] + "requirements": ["pyjvcprojector==1.0.11"] } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index dcc9e5cff51..b69d3b0118b 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Iterable import logging from typing import Any @@ -74,11 +75,13 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.device.power_on() + await asyncio.sleep(1) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.device.power_off() + await asyncio.sleep(1) await self.coordinator.async_refresh() async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index cbc4144ef26..7a3ca1dd781 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1902,7 +1902,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.9 +pyjvcprojector==1.0.11 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b0e2ce3373..c747b8d3078 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1483,7 +1483,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.9 +pyjvcprojector==1.0.11 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From b3c1a86194c83da647f8e1acf11b296ccfac08da Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 29 Apr 2024 18:34:20 +0200 Subject: [PATCH 135/272] Update frontend to 20240429.0 (#116404) --- 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 a5446f688ba..e271903a27d 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==20240426.0"] + "requirements": ["home-assistant-frontend==20240429.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b4223d7b33..a2eb0f1254c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 7a3ca1dd781..a4272a2dd6a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c747b8d3078..d54dfdc8e3a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From dfc198cae0d15f146c5264211d622190e81aad12 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 29 Apr 2024 19:33:31 +0200 Subject: [PATCH 136/272] Remove strict connection (#116396) --- homeassistant/components/cloud/__init__.py | 8 -------- homeassistant/components/cloud/prefs.py | 11 +---------- homeassistant/components/http/__init__.py | 18 +++--------------- tests/components/cloud/test_http_api.py | 2 -- tests/components/cloud/test_init.py | 2 ++ tests/components/cloud/test_prefs.py | 1 + .../components/cloud/test_strict_connection.py | 1 + tests/components/http/test_init.py | 2 ++ tests/helpers/test_service.py | 5 ++--- tests/scripts/test_check_config.py | 2 -- 10 files changed, 12 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2552fe4bf5c..13f1d34b5cd 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -30,7 +30,6 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, - SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -458,10 +457,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 72207513ca9..b4e692d02c4 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -365,16 +365,7 @@ class CloudPreferences: @property def strict_connection(self) -> http.const.StrictConnectionMode: """Return the strict connection mode.""" - mode = self._prefs.get(PREF_STRICT_CONNECTION) - - if mode is None: - # Set to default value - # We store None in the store as the default value to detect if the user has changed the - # value or not. - mode = http.const.StrictConnectionMode.DISABLED - elif not isinstance(mode, http.const.StrictConnectionMode): - mode = http.const.StrictConnectionMode(mode) - return mode + return http.const.StrictConnectionMode.DISABLED async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 83601599d88..c783d2f0b71 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,7 +10,7 @@ import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, Required, TypedDict, cast +from typing import Any, Final, TypedDict, cast from urllib.parse import quote_plus, urljoin from aiohttp import web @@ -36,7 +36,6 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, - SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -146,9 +145,6 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, - vol.Optional( - CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED - ): vol.Coerce(StrictConnectionMode), } ), ) @@ -172,7 +168,6 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str - strict_connection: Required[StrictConnectionMode] @bind_hass @@ -239,7 +234,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, - strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], + strict_connection_non_cloud=StrictConnectionMode.DISABLED, ) async def stop_server(event: Event) -> None: @@ -620,7 +615,7 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: if not user.is_admin: raise Unauthorized(context=call.context) - if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: + if StrictConnectionMode.DISABLED is StrictConnectionMode.DISABLED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="strict_connection_not_enabled_non_cloud", @@ -652,10 +647,3 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d9d2b5c6742..1e4dc3173e2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -915,7 +915,6 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, - "strict_connection": StrictConnectionMode.DROP_CONNECTION, } ) response = await client.receive_json() @@ -926,7 +925,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DROP_CONNECTION @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bc4526975da..d917dc12a7c 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -303,6 +303,7 @@ async def test_cloud_logout( assert cloud.is_logged_in is False +@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -323,6 +324,7 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 57715fe2bdf..a8ce88f5700 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -181,6 +181,7 @@ async def test_tts_default_voice_legacy_gender( assert cloud.client.prefs.tts_default_voice == (expected_language, voice) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize("mode", list(StrictConnectionMode)) async def test_strict_connection_convertion( hass: HomeAssistant, diff --git a/tests/components/cloud/test_strict_connection.py b/tests/components/cloud/test_strict_connection.py index f275bc4d2dd..c3329740207 100644 --- a/tests/components/cloud/test_strict_connection.py +++ b/tests/components/cloud/test_strict_connection.py @@ -226,6 +226,7 @@ async def _guard_page_unauthorized_request( assert await req.text() == await hass.async_add_executor_job(read_guard_page) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( "test_func", [ diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b554737e7b3..9e576e10f4d 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -527,6 +527,7 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text +@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -544,6 +545,7 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e32768ee33e..c9d92c2f25a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -800,11 +800,10 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert proxy_load_services_files.mock_calls[0][1][1] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), - await async_get_integration(hass, "http"), # system_health requires http ] ) - assert len(descriptions) == 2 + assert len(descriptions) == 1 assert DOMAIN_GROUP in descriptions assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"] @@ -838,7 +837,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 3 + assert len(descriptions) == 2 assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 76acb2ff678..79c64259f8b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -135,7 +134,6 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", - "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], } From ac45d20e1f00b7b49123e20ccb3b98cb5cb5f6d7 Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:08:36 +0200 Subject: [PATCH 137/272] Bump fyta_cli to 0.4.1 (#115918) * bump fyta_cli to 0.4.0 * Update PLANT_STATUS and add PLANT_MEASUREMENT_STATUS * bump fyta_cli to v0.4.0 * minor adjustments of states to API documentation --- homeassistant/components/fyta/manifest.json | 2 +- homeassistant/components/fyta/sensor.py | 28 +++++++---- homeassistant/components/fyta/strings.json | 53 +++++++++++---------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 49 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/fyta/manifest.json b/homeassistant/components/fyta/manifest.json index 55255777994..020ab330152 100644 --- a/homeassistant/components/fyta/manifest.json +++ b/homeassistant/components/fyta/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/fyta", "integration_type": "hub", "iot_class": "cloud_polling", - "requirements": ["fyta_cli==0.3.5"] + "requirements": ["fyta_cli==0.4.1"] } diff --git a/homeassistant/components/fyta/sensor.py b/homeassistant/components/fyta/sensor.py index 2b9e8e3de07..c3e90cef28e 100644 --- a/homeassistant/components/fyta/sensor.py +++ b/homeassistant/components/fyta/sensor.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from datetime import datetime from typing import Final -from fyta_cli.fyta_connector import PLANT_STATUS +from fyta_cli.fyta_connector import PLANT_MEASUREMENT_STATUS, PLANT_STATUS from homeassistant.components.sensor import ( SensorDeviceClass, @@ -34,7 +34,15 @@ class FytaSensorEntityDescription(SensorEntityDescription): ) -PLANT_STATUS_LIST: list[str] = ["too_low", "low", "perfect", "high", "too_high"] +PLANT_STATUS_LIST: list[str] = ["deleted", "doing_great", "need_attention", "no_sensor"] +PLANT_MEASUREMENT_STATUS_LIST: list[str] = [ + "no_data", + "too_low", + "low", + "perfect", + "high", + "too_high", +] SENSORS: Final[list[FytaSensorEntityDescription]] = [ FytaSensorEntityDescription( @@ -52,29 +60,29 @@ SENSORS: Final[list[FytaSensorEntityDescription]] = [ key="temperature_status", translation_key="temperature_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="light_status", translation_key="light_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="moisture_status", translation_key="moisture_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="salinity_status", translation_key="salinity_status", device_class=SensorDeviceClass.ENUM, - options=PLANT_STATUS_LIST, - value_fn=PLANT_STATUS.get, + options=PLANT_MEASUREMENT_STATUS_LIST, + value_fn=PLANT_MEASUREMENT_STATUS.get, ), FytaSensorEntityDescription( key="temperature", diff --git a/homeassistant/components/fyta/strings.json b/homeassistant/components/fyta/strings.json index 3df851489bc..bacd24555b0 100644 --- a/homeassistant/components/fyta/strings.json +++ b/homeassistant/components/fyta/strings.json @@ -36,6 +36,16 @@ "plant_status": { "name": "Plant state", "state": { + "deleted": "Deleted", + "doing_great": "Doing great", + "need_attention": "Needs attention", + "no_sensor": "No sensor" + } + }, + "temperature_status": { + "name": "Temperature state", + "state": { + "no_data": "No data", "too_low": "Too low", "low": "Low", "perfect": "Perfect", @@ -43,44 +53,37 @@ "too_high": "Too high" } }, - "temperature_status": { - "name": "Temperature state", - "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" - } - }, "light_status": { "name": "Light state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "moisture_status": { "name": "Moisture state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "salinity_status": { "name": "Salinity state", "state": { - "too_low": "[%key:component::fyta::entity::sensor::plant_status::state::too_low%]", - "low": "[%key:component::fyta::entity::sensor::plant_status::state::low%]", - "perfect": "[%key:component::fyta::entity::sensor::plant_status::state::perfect%]", - "high": "[%key:component::fyta::entity::sensor::plant_status::state::high%]", - "too_high": "[%key:component::fyta::entity::sensor::plant_status::state::too_high%]" + "no_data": "[%key:component::fyta::entity::sensor::temperature_status::state::no_data%]", + "too_low": "[%key:component::fyta::entity::sensor::temperature_status::state::too_low%]", + "low": "[%key:component::fyta::entity::sensor::temperature_status::state::low%]", + "perfect": "[%key:component::fyta::entity::sensor::temperature_status::state::perfect%]", + "high": "[%key:component::fyta::entity::sensor::temperature_status::state::high%]", + "too_high": "[%key:component::fyta::entity::sensor::temperature_status::state::too_high%]" } }, "light": { diff --git a/requirements_all.txt b/requirements_all.txt index 205907c1288..6e41f3b743e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -900,7 +900,7 @@ freesms==0.2.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.5 +fyta_cli==0.4.1 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e5aecc63e0..a2625b50e87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -735,7 +735,7 @@ freebox-api==1.1.0 fritzconnection[qr]==1.13.2 # homeassistant.components.fyta -fyta_cli==0.3.5 +fyta_cli==0.4.1 # homeassistant.components.google_translate gTTS==2.2.4 From 7ee79002b392c28abd07d6234163fa139614ed5f Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Mon, 29 Apr 2024 17:09:07 +0200 Subject: [PATCH 138/272] Store access token in entry for Fyta (#116260) * save access_token and expiration date in ConfigEntry * add MINOR_VERSION and async_migrate_entry * shorten reading of expiration from config entry * add additional consts and test for config entry migration * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/__init__.py Co-authored-by: Joost Lekkerkerker * omit check for datetime data type * Update homeassistant/components/fyta/__init__.py Co-authored-by: Joost Lekkerkerker * Update homeassistant/components/fyta/coordinator.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/fyta/__init__.py | 52 ++++++++++++++++++-- homeassistant/components/fyta/config_flow.py | 17 +++++-- homeassistant/components/fyta/const.py | 1 + homeassistant/components/fyta/coordinator.py | 25 ++++++++-- tests/components/fyta/conftest.py | 26 +++++++++- tests/components/fyta/test_config_flow.py | 36 ++++++++++---- tests/components/fyta/test_init.py | 42 ++++++++++++++++ 7 files changed, 179 insertions(+), 20 deletions(-) create mode 100644 tests/components/fyta/test_init.py diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index febd5b94469..205dd97a42f 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -2,15 +2,23 @@ from __future__ import annotations +from datetime import datetime import logging +from typing import Any +from zoneinfo import ZoneInfo from fyta_cli.fyta_connector import FytaConnector from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant -from .const import DOMAIN +from .const import CONF_EXPIRATION, DOMAIN from .coordinator import FytaCoordinator _LOGGER = logging.getLogger(__name__) @@ -22,11 +30,16 @@ PLATFORMS = [ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Fyta integration.""" + tz: str = hass.config.time_zone username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + access_token: str = entry.data[CONF_ACCESS_TOKEN] + expiration: datetime = datetime.fromisoformat( + entry.data[CONF_EXPIRATION] + ).astimezone(ZoneInfo(tz)) - fyta = FytaConnector(username, password) + fyta = FytaConnector(username, password, access_token, expiration, tz) coordinator = FytaCoordinator(hass, fyta) @@ -47,3 +60,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version > 1: + # This means the user has downgraded from a future version + return False + + if config_entry.version == 1: + new = {**config_entry.data} + if config_entry.minor_version < 2: + fyta = FytaConnector( + config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] + ) + credentials: dict[str, Any] = await fyta.login() + await fyta.client.close() + + new[CONF_ACCESS_TOKEN] = credentials[CONF_ACCESS_TOKEN] + new[CONF_EXPIRATION] = credentials[CONF_EXPIRATION].isoformat() + + hass.config_entries.async_update_entry( + config_entry, data=new, minor_version=2, version=1 + ) + + _LOGGER.debug( + "Migration to version %s.%s successful", + config_entry.version, + config_entry.minor_version, + ) + + return True diff --git a/homeassistant/components/fyta/config_flow.py b/homeassistant/components/fyta/config_flow.py index e11c024ec1f..3d83c099ac3 100644 --- a/homeassistant/components/fyta/config_flow.py +++ b/homeassistant/components/fyta/config_flow.py @@ -17,7 +17,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import DOMAIN +from .const import CONF_EXPIRATION, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,14 +31,19 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Fyta.""" VERSION = 1 - _entry: ConfigEntry | None = None + MINOR_VERSION = 2 + + def __init__(self) -> None: + """Initialize FytaConfigFlow.""" + self.credentials: dict[str, Any] = {} + self._entry: ConfigEntry | None = None async def async_auth(self, user_input: Mapping[str, Any]) -> dict[str, str]: """Reusable Auth Helper.""" fyta = FytaConnector(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]) try: - await fyta.login() + self.credentials = await fyta.login() except FytaConnectionError: return {"base": "cannot_connect"} except FytaAuthentificationError: @@ -51,6 +56,10 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): finally: await fyta.client.close() + self.credentials[CONF_EXPIRATION] = self.credentials[ + CONF_EXPIRATION + ].isoformat() + return {} async def async_step_user( @@ -62,6 +71,7 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]}) if not (errors := await self.async_auth(user_input)): + user_input |= self.credentials return self.async_create_entry( title=user_input[CONF_USERNAME], data=user_input ) @@ -85,6 +95,7 @@ class FytaConfigFlow(ConfigFlow, domain=DOMAIN): assert self._entry is not None if user_input and not (errors := await self.async_auth(user_input)): + user_input |= self.credentials return self.async_update_reload_and_abort( self._entry, data={**self._entry.data, **user_input} ) diff --git a/homeassistant/components/fyta/const.py b/homeassistant/components/fyta/const.py index f99735dc6fa..bf4636a713a 100644 --- a/homeassistant/components/fyta/const.py +++ b/homeassistant/components/fyta/const.py @@ -1,3 +1,4 @@ """Const for fyta integration.""" DOMAIN = "fyta" +CONF_EXPIRATION = "expiration" diff --git a/homeassistant/components/fyta/coordinator.py b/homeassistant/components/fyta/coordinator.py index 65bd0cb532c..021bddf2cf8 100644 --- a/homeassistant/components/fyta/coordinator.py +++ b/homeassistant/components/fyta/coordinator.py @@ -12,10 +12,13 @@ from fyta_cli.fyta_exceptions import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import CONF_EXPIRATION + _LOGGER = logging.getLogger(__name__) @@ -39,17 +42,33 @@ class FytaCoordinator(DataUpdateCoordinator[dict[int, dict[str, Any]]]): ) -> dict[int, dict[str, Any]]: """Fetch data from API endpoint.""" - if self.fyta.expiration is None or self.fyta.expiration < datetime.now(): + if ( + self.fyta.expiration is None + or self.fyta.expiration.timestamp() < datetime.now().timestamp() + ): await self.renew_authentication() return await self.fyta.update_all_plants() - async def renew_authentication(self) -> None: + async def renew_authentication(self) -> bool: """Renew access token for FYTA API.""" + credentials: dict[str, Any] = {} try: - await self.fyta.login() + credentials = await self.fyta.login() except FytaConnectionError as ex: raise ConfigEntryNotReady from ex except (FytaAuthentificationError, FytaPasswordError) as ex: raise ConfigEntryAuthFailed from ex + + new_config_entry = {**self.config_entry.data} + new_config_entry[CONF_ACCESS_TOKEN] = credentials[CONF_ACCESS_TOKEN] + new_config_entry[CONF_EXPIRATION] = credentials[CONF_EXPIRATION].isoformat() + + self.hass.config_entries.async_update_entry( + self.config_entry, data=new_config_entry + ) + + _LOGGER.debug("Credentials successfully updated") + + return True diff --git a/tests/components/fyta/conftest.py b/tests/components/fyta/conftest.py index efebf9827b9..9250c26926a 100644 --- a/tests/components/fyta/conftest.py +++ b/tests/components/fyta/conftest.py @@ -5,6 +5,11 @@ from unittest.mock import AsyncMock, patch import pytest +from homeassistant.components.fyta.const import CONF_EXPIRATION +from homeassistant.const import CONF_ACCESS_TOKEN + +from .test_config_flow import ACCESS_TOKEN, EXPIRATION + @pytest.fixture def mock_fyta(): @@ -15,7 +20,26 @@ def mock_fyta(): "homeassistant.components.fyta.config_flow.FytaConnector", return_value=mock_fyta_api, ) as mock_fyta_api: - mock_fyta_api.return_value.login.return_value = {} + mock_fyta_api.return_value.login.return_value = { + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } + yield mock_fyta_api + + +@pytest.fixture +def mock_fyta_init(): + """Build a fixture for the Fyta API that connects successfully and returns one device.""" + + mock_fyta_api = AsyncMock() + with patch( + "homeassistant.components.fyta.FytaConnector", + return_value=mock_fyta_api, + ) as mock_fyta_api: + mock_fyta_api.return_value.login.return_value = { + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: EXPIRATION, + } yield mock_fyta_api diff --git a/tests/components/fyta/test_config_flow.py b/tests/components/fyta/test_config_flow.py index 6aad6295819..69478d04ca0 100644 --- a/tests/components/fyta/test_config_flow.py +++ b/tests/components/fyta/test_config_flow.py @@ -1,5 +1,6 @@ """Test the fyta config flow.""" +from datetime import UTC, datetime from unittest.mock import AsyncMock from fyta_cli.fyta_exceptions import ( @@ -10,8 +11,8 @@ from fyta_cli.fyta_exceptions import ( import pytest from homeassistant import config_entries -from homeassistant.components.fyta.const import DOMAIN -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -19,10 +20,12 @@ from tests.common import MockConfigEntry USERNAME = "fyta_user" PASSWORD = "fyta_pass" +ACCESS_TOKEN = "123xyz" +EXPIRATION = datetime.fromisoformat("2024-12-31T10:00:00").astimezone(UTC) async def test_user_flow( - hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry + hass: HomeAssistant, mock_fyta: AsyncMock, mock_setup_entry: AsyncMock ) -> None: """Test we get the form.""" @@ -39,7 +42,12 @@ async def test_user_flow( assert result2["type"] is FlowResultType.CREATE_ENTRY assert result2["title"] == USERNAME - assert result2["data"] == {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + assert result2["data"] == { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: "2024-12-31T10:00:00+00:00", + } assert len(mock_setup_entry.mock_calls) == 1 @@ -57,7 +65,7 @@ async def test_form_exceptions( exception: Exception, error: dict[str, str], mock_fyta: AsyncMock, - mock_setup_entry, + mock_setup_entry: AsyncMock, ) -> None: """Test we can handle Form exceptions.""" @@ -89,6 +97,8 @@ async def test_form_exceptions( assert result["title"] == USERNAME assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" assert len(mock_setup_entry.mock_calls) == 1 @@ -134,14 +144,19 @@ async def test_reauth( exception: Exception, error: dict[str, str], mock_fyta: AsyncMock, - mock_setup_entry, + mock_setup_entry: AsyncMock, ) -> None: """Test reauth-flow works.""" entry = MockConfigEntry( domain=DOMAIN, title=USERNAME, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_EXPIRATION: "2024-06-30T10:00:00+00:00", + }, ) entry.add_to_hass(hass) @@ -157,7 +172,8 @@ async def test_reauth( # tests with connection error result = await hass.config_entries.flow.async_configure( - result["flow_id"], {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD} + result["flow_id"], + {CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, ) await hass.async_block_till_done() @@ -178,5 +194,5 @@ async def test_reauth( assert result["reason"] == "reauth_successful" assert entry.data[CONF_USERNAME] == "other_username" assert entry.data[CONF_PASSWORD] == "other_password" - - assert len(mock_setup_entry.mock_calls) == 1 + assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" diff --git a/tests/components/fyta/test_init.py b/tests/components/fyta/test_init.py new file mode 100644 index 00000000000..844a818df85 --- /dev/null +++ b/tests/components/fyta/test_init.py @@ -0,0 +1,42 @@ +"""Test the initialization.""" + +from unittest.mock import AsyncMock + +from homeassistant.components.fyta.const import CONF_EXPIRATION, DOMAIN +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .test_config_flow import ACCESS_TOKEN, PASSWORD, USERNAME + +from tests.common import MockConfigEntry + + +async def test_migrate_config_entry( + hass: HomeAssistant, + mock_fyta_init: AsyncMock, +) -> None: + """Test successful migration of entry data.""" + entry = MockConfigEntry( + domain=DOMAIN, + title=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + version=1, + minor_version=1, + ) + entry.add_to_hass(hass) + + assert entry.version == 1 + assert entry.minor_version == 1 + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 + assert entry.minor_version == 2 + assert entry.data[CONF_USERNAME] == USERNAME + assert entry.data[CONF_PASSWORD] == PASSWORD + assert entry.data[CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert entry.data[CONF_EXPIRATION] == "2024-12-31T10:00:00+00:00" From 99e3236fb7b6fe2a12a09d17ceff0590694e4bf3 Mon Sep 17 00:00:00 2001 From: "Mr. Bubbles" Date: Mon, 29 Apr 2024 17:00:13 +0200 Subject: [PATCH 139/272] Deprecate YAML configuration of Habitica (#116374) Add deprecation issue for yaml import --- .../components/habitica/config_flow.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/habitica/config_flow.py b/homeassistant/components/habitica/config_flow.py index 9ee2aef40ba..9a8852b731d 100644 --- a/homeassistant/components/habitica/config_flow.py +++ b/homeassistant/components/habitica/config_flow.py @@ -10,9 +10,10 @@ import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_URL -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .const import CONF_API_USER, DEFAULT_URL, DOMAIN @@ -79,6 +80,20 @@ class HabiticaConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_data): """Import habitica config from configuration.yaml.""" + + async_create_issue( + self.hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.11.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Habitica", + }, + ) return await self.async_step_user(import_data) From 39d923dc0273e1728d01e36bf15d2be0d60a86c6 Mon Sep 17 00:00:00 2001 From: Steve Easley Date: Mon, 29 Apr 2024 12:25:16 -0400 Subject: [PATCH 140/272] Fix jvcprojector command timeout with some projectors (#116392) * Fix projector timeout in pyprojector lib v1.0.10 * Fix projector timeout by increasing time between power command and refresh. * Bump jvcprojector lib to ensure unknown power states are handled --- homeassistant/components/jvc_projector/manifest.json | 2 +- homeassistant/components/jvc_projector/remote.py | 3 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json index de7e77197f2..d3e1bf3d940 100644 --- a/homeassistant/components/jvc_projector/manifest.json +++ b/homeassistant/components/jvc_projector/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["jvcprojector"], - "requirements": ["pyjvcprojector==1.0.9"] + "requirements": ["pyjvcprojector==1.0.11"] } diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py index dcc9e5cff51..b69d3b0118b 100644 --- a/homeassistant/components/jvc_projector/remote.py +++ b/homeassistant/components/jvc_projector/remote.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio from collections.abc import Iterable import logging from typing import Any @@ -74,11 +75,13 @@ class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self.device.power_on() + await asyncio.sleep(1) await self.coordinator.async_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self.device.power_off() + await asyncio.sleep(1) await self.coordinator.async_refresh() async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 6e41f3b743e..1f83f00bb53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1902,7 +1902,7 @@ pyisy==3.1.14 pyitachip2ir==0.0.7 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.9 +pyjvcprojector==1.0.11 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2625b50e87..060287d637a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1483,7 +1483,7 @@ pyiss==1.0.1 pyisy==3.1.14 # homeassistant.components.jvc_projector -pyjvcprojector==1.0.9 +pyjvcprojector==1.0.11 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From 8f2d10c49a762dd54edae9d0809cab6406be258a Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 29 Apr 2024 19:33:31 +0200 Subject: [PATCH 141/272] Remove strict connection (#116396) --- homeassistant/components/cloud/__init__.py | 8 -------- homeassistant/components/cloud/prefs.py | 11 +---------- homeassistant/components/http/__init__.py | 18 +++--------------- tests/components/cloud/test_http_api.py | 2 -- tests/components/cloud/test_init.py | 2 ++ tests/components/cloud/test_prefs.py | 1 + .../components/cloud/test_strict_connection.py | 1 + tests/components/http/test_init.py | 2 ++ tests/helpers/test_service.py | 5 ++--- tests/scripts/test_check_config.py | 2 -- 10 files changed, 12 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2552fe4bf5c..13f1d34b5cd 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -30,7 +30,6 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, - SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -458,10 +457,3 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 72207513ca9..b4e692d02c4 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -365,16 +365,7 @@ class CloudPreferences: @property def strict_connection(self) -> http.const.StrictConnectionMode: """Return the strict connection mode.""" - mode = self._prefs.get(PREF_STRICT_CONNECTION) - - if mode is None: - # Set to default value - # We store None in the store as the default value to detect if the user has changed the - # value or not. - mode = http.const.StrictConnectionMode.DISABLED - elif not isinstance(mode, http.const.StrictConnectionMode): - mode = http.const.StrictConnectionMode(mode) - return mode + return http.const.StrictConnectionMode.DISABLED async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 83601599d88..c783d2f0b71 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,7 +10,7 @@ import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, Required, TypedDict, cast +from typing import Any, Final, TypedDict, cast from urllib.parse import quote_plus, urljoin from aiohttp import web @@ -36,7 +36,6 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, - SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -146,9 +145,6 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, - vol.Optional( - CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED - ): vol.Coerce(StrictConnectionMode), } ), ) @@ -172,7 +168,6 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str - strict_connection: Required[StrictConnectionMode] @bind_hass @@ -239,7 +234,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, - strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], + strict_connection_non_cloud=StrictConnectionMode.DISABLED, ) async def stop_server(event: Event) -> None: @@ -620,7 +615,7 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: if not user.is_admin: raise Unauthorized(context=call.context) - if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: + if StrictConnectionMode.DISABLED is StrictConnectionMode.DISABLED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="strict_connection_not_enabled_non_cloud", @@ -652,10 +647,3 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } - - hass.services.async_register( - DOMAIN, - "create_temporary_strict_connection_url", - create_temporary_strict_connection_url, - supports_response=SupportsResponse.ONLY, - ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index d9d2b5c6742..1e4dc3173e2 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -915,7 +915,6 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, - "strict_connection": StrictConnectionMode.DROP_CONNECTION, } ) response = await client.receive_json() @@ -926,7 +925,6 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") - assert cloud.client.prefs.strict_connection is StrictConnectionMode.DROP_CONNECTION @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bc4526975da..d917dc12a7c 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -303,6 +303,7 @@ async def test_cloud_logout( assert cloud.is_logged_in is False +@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -323,6 +324,7 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index 57715fe2bdf..a8ce88f5700 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -181,6 +181,7 @@ async def test_tts_default_voice_legacy_gender( assert cloud.client.prefs.tts_default_voice == (expected_language, voice) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize("mode", list(StrictConnectionMode)) async def test_strict_connection_convertion( hass: HomeAssistant, diff --git a/tests/components/cloud/test_strict_connection.py b/tests/components/cloud/test_strict_connection.py index f275bc4d2dd..c3329740207 100644 --- a/tests/components/cloud/test_strict_connection.py +++ b/tests/components/cloud/test_strict_connection.py @@ -226,6 +226,7 @@ async def _guard_page_unauthorized_request( assert await req.text() == await hass.async_add_executor_job(read_guard_page) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( "test_func", [ diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b554737e7b3..9e576e10f4d 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -527,6 +527,7 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text +@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -544,6 +545,7 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) +@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e32768ee33e..c9d92c2f25a 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -800,11 +800,10 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert proxy_load_services_files.mock_calls[0][1][1] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), - await async_get_integration(hass, "http"), # system_health requires http ] ) - assert len(descriptions) == 2 + assert len(descriptions) == 1 assert DOMAIN_GROUP in descriptions assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"] @@ -838,7 +837,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 3 + assert len(descriptions) == 2 assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 76acb2ff678..79c64259f8b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,7 +5,6 @@ from unittest.mock import patch import pytest -from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -135,7 +134,6 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", - "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], } From 06e032b8386280537c2f8b3bf152c523002bf97b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 29 Apr 2024 18:34:20 +0200 Subject: [PATCH 142/272] Update frontend to 20240429.0 (#116404) --- 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 a5446f688ba..e271903a27d 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==20240426.0"] + "requirements": ["home-assistant-frontend==20240429.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1b4223d7b33..a2eb0f1254c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1f83f00bb53..4e788f9aa80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 060287d637a..16975128dae 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240426.0 +home-assistant-frontend==20240429.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From a7faf2710f2c5ed62a414b7ec275de7244ce91f1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 29 Apr 2024 19:44:22 +0200 Subject: [PATCH 143/272] Bump version to 2024.5.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 07f4058ea19..35be5835088 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 68b38bb516b..575063541e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b2" +version = "2024.5.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From d1c58467c549ad2cabe2a6c87717afc0f55c8464 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Apr 2024 20:13:36 +0200 Subject: [PATCH 144/272] Remove semicolon in Modbus (#116399) --- homeassistant/components/modbus/modbus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index bd7eed8235c..a5c0867dedb 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -245,7 +245,7 @@ async def async_modbus_setup( translation_key="deprecated_restart", ) _LOGGER.warning( - "`modbus.restart`: is deprecated and will be removed in version 2024.11" + "`modbus.restart` is deprecated and will be removed in version 2024.11" ) async_dispatcher_send(hass, SIGNAL_START_ENTITY) hub = hub_collect[service.data[ATTR_HUB]] From 50d83bbdbfb8698f276e758d7e30db6f1bdd8961 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:19:14 +0200 Subject: [PATCH 145/272] Fix error handling in Shell Command integration (#116409) * raise proper HomeAssistantError on command timeout * raise proper HomeAssistantError on non-utf8 command output * add error translation and test it * Update homeassistant/components/shell_command/strings.json * Update tests/components/shell_command/test_init.py --------- Co-authored-by: G Johansson --- .../components/shell_command/__init__.py | 21 ++++++++++++++----- .../components/shell_command/strings.json | 10 +++++++++ tests/components/shell_command/test_init.py | 12 ++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/shell_command/strings.json diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 95bbb01bcfb..c2c384e39aa 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType @@ -91,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() - except TimeoutError: + except TimeoutError as err: _LOGGER.error( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT ) @@ -103,7 +103,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process._transport.close() # type: ignore[attr-defined] del process - raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={ + "command": cmd, + "timeout": str(COMMAND_TIMEOUT), + }, + ) from err if stdout_data: _LOGGER.debug( @@ -135,11 +142,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: service_response["stdout"] = stdout_data.decode("utf-8").strip() if stderr_data: service_response["stderr"] = stderr_data.decode("utf-8").strip() - except UnicodeDecodeError: + except UnicodeDecodeError as err: _LOGGER.exception( "Unable to handle non-utf8 output of command: `%s`", cmd ) - raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="non_utf8_output", + translation_placeholders={"command": cmd}, + ) from err return service_response return None diff --git a/homeassistant/components/shell_command/strings.json b/homeassistant/components/shell_command/strings.json new file mode 100644 index 00000000000..c87dac15b2d --- /dev/null +++ b/homeassistant/components/shell_command/strings.json @@ -0,0 +1,10 @@ +{ + "exceptions": { + "timeout": { + "message": "Timed out running command: `{command}`, after: {timeout} seconds" + }, + "non_utf8_output": { + "message": "Unable to handle non-utf8 output of command: `{command}`" + } + } +} diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 93b06ddf9d8..526ac1643ec 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components import shell_command from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.setup import async_setup_component @@ -199,7 +199,10 @@ async def test_non_text_stdout_capture( assert not response # Non-text output throws with 'return_response' - with pytest.raises(UnicodeDecodeError): + with pytest.raises( + HomeAssistantError, + match="Unable to handle non-utf8 output of command: `curl -o - https://raw.githubusercontent.com/home-assistant/assets/master/misc/loading-screen.gif`", + ): response = await hass.services.async_call( "shell_command", "output_image", blocking=True, return_response=True ) @@ -258,7 +261,10 @@ async def test_do_not_run_forever( side_effect=mock_create_subprocess_shell, ), ): - with pytest.raises(TimeoutError): + with pytest.raises( + HomeAssistantError, + match="Timed out running command: `mock_sleep 10000`, after: 0.001 seconds", + ): await hass.services.async_call( shell_command.DOMAIN, "test_service", From a6fdd4e1e23f02f375841329eaec1360ad19a29b Mon Sep 17 00:00:00 2001 From: rale Date: Mon, 29 Apr 2024 13:43:46 -0500 Subject: [PATCH 146/272] Report webOS media player state (#113774) * support for webos media player state * add test coverage and don't use assumed state if media player state is available * fallback to assumed state if media state isn't available Co-authored-by: Shay Levy --------- Co-authored-by: Shay Levy --- .../components/webostv/media_player.py | 14 +++++++++++++ tests/components/webostv/test_media_player.py | 21 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 647cf64ea8e..34ff8aafca2 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -241,6 +241,20 @@ class LgWebOSMediaPlayerEntity(RestoreEntity, MediaPlayerEntity): name=self._device_name, ) + self._attr_assumed_state = True + if ( + self._client.media_state is not None + and self._client.media_state.get("foregroundAppInfo") is not None + ): + self._attr_assumed_state = False + for entry in self._client.media_state.get("foregroundAppInfo"): + if entry.get("playState") == "playing": + self._attr_state = MediaPlayerState.PLAYING + elif entry.get("playState") == "paused": + self._attr_state = MediaPlayerState.PAUSED + elif entry.get("playState") == "unloaded": + self._attr_state = MediaPlayerState.IDLE + if self._client.system_info is not None or self.state != MediaPlayerState.OFF: maj_v = self._client.software_info.get("major_ver") min_v = self._client.software_info.get("minor_ver") diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index 6608c107599..6c4aeb5e984 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -21,6 +21,7 @@ from homeassistant.components.media_player import ( SERVICE_SELECT_SOURCE, MediaPlayerDeviceClass, MediaPlayerEntityFeature, + MediaPlayerState, MediaType, ) from homeassistant.components.webostv.const import ( @@ -811,3 +812,23 @@ async def test_reauth_reconnect(hass: HomeAssistant, client, monkeypatch) -> Non assert "context" in flow assert flow["context"].get("source") == SOURCE_REAUTH assert flow["context"].get("entry_id") == entry.entry_id + + +async def test_update_media_state(hass: HomeAssistant, client, monkeypatch) -> None: + """Test updating media state.""" + await setup_webostv(hass) + + data = {"foregroundAppInfo": [{"playState": "playing"}]} + monkeypatch.setattr(client, "media_state", data) + await client.mock_state_update() + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PLAYING + + data = {"foregroundAppInfo": [{"playState": "paused"}]} + monkeypatch.setattr(client, "media_state", data) + await client.mock_state_update() + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.PAUSED + + data = {"foregroundAppInfo": [{"playState": "unloaded"}]} + monkeypatch.setattr(client, "media_state", data) + await client.mock_state_update() + assert hass.states.get(ENTITY_ID).state == MediaPlayerState.IDLE From c5953045d4c40c3e665efd10066204b05bd89a3d Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:48:54 +0200 Subject: [PATCH 147/272] Add error translations to AVM Fritz!Tools (#116413) --- homeassistant/components/fritz/common.py | 17 +++++++++++++---- homeassistant/components/fritz/services.py | 5 +++-- homeassistant/components/fritz/strings.json | 13 +++++++++++++ 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index f051c824847..ec893e99ab1 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -443,7 +443,10 @@ class FritzBoxTools( ) except Exception as ex: # pylint: disable=[broad-except] if not self.hass.is_stopping: - raise HomeAssistantError("Error refreshing hosts info") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="error_refresh_hosts_info", + ) from ex hosts: dict[str, Device] = {} if hosts_attributes: @@ -730,7 +733,9 @@ class FritzBoxTools( _LOGGER.debug("FRITZ!Box service: %s", service_call.service) if not self.connection: - raise HomeAssistantError("Unable to establish a connection") + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="unable_to_connect" + ) try: if service_call.service == SERVICE_REBOOT: @@ -765,9 +770,13 @@ class FritzBoxTools( return except (FritzServiceError, FritzActionError) as ex: - raise HomeAssistantError("Service or parameter unknown") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_parameter_unknown" + ) from ex except FritzConnectionException as ex: - raise HomeAssistantError("Service not supported") from ex + raise HomeAssistantError( + translation_domain=DOMAIN, translation_key="service_not_supported" + ) from ex class AvmWrapper(FritzBoxTools): # pylint: disable=hass-enforce-coordinator-module diff --git a/homeassistant/components/fritz/services.py b/homeassistant/components/fritz/services.py index 47fb0ceb1c6..f0131c6bae2 100644 --- a/homeassistant/components/fritz/services.py +++ b/homeassistant/components/fritz/services.py @@ -55,8 +55,9 @@ async def async_setup_services(hass: HomeAssistant) -> None: ) ): raise HomeAssistantError( - f"Failed to call service '{service_call.service}'. Config entry for" - " target not found" + translation_domain=DOMAIN, + translation_key="config_entry_not_found", + translation_placeholders={"service": service_call.service}, ) for entry_id in fritzbox_entry_ids: diff --git a/homeassistant/components/fritz/strings.json b/homeassistant/components/fritz/strings.json index a96c3b8ac28..30603ca9032 100644 --- a/homeassistant/components/fritz/strings.json +++ b/homeassistant/components/fritz/strings.json @@ -192,5 +192,18 @@ } } } + }, + "exceptions": { + "config_entry_not_found": { + "message": "Failed to call service \"{service}\". Config entry for target not found" + }, + "service_parameter_unknown": { "message": "Service or parameter unknown" }, + "service_not_supported": { "message": "Service not supported" }, + "error_refresh_hosts_info": { + "message": "Error refreshing hosts info" + }, + "unable_to_connect": { + "message": "Unable to establish a connection" + } } } From f001e8524a112c262106c02f4d3d68e1da6d0998 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:10:45 +0200 Subject: [PATCH 148/272] Add Workarea cutting height to Husqvarna Automower (#116115) * add work_area cutting_height * add * add default work_area * ruff/mypy * better names * fit to api bump * tweaks * more tweaks * layout * address review * change entity name * tweak test * cleanup entities * fix for mowers with no workareas * assure not other entities get deleted * sort & remove one callback * remove typing callbacks * rename entity to entity_entry --- .../components/husqvarna_automower/number.py | 159 +++++++++++++++-- .../husqvarna_automower/strings.json | 6 + .../husqvarna_automower/fixtures/mower.json | 9 +- .../snapshots/test_diagnostics.ambr | 8 +- .../snapshots/test_number.ambr | 168 ++++++++++++++++++ .../husqvarna_automower/test_number.py | 73 +++++++- 6 files changed, 406 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index e2e617b427b..a3458cd319b 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -1,19 +1,21 @@ """Creates the number entities for the mower.""" +import asyncio from collections.abc import Awaitable, Callable from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any from aioautomower.exceptions import ApiException -from aioautomower.model import MowerAttributes +from aioautomower.model import MowerAttributes, WorkArea from aioautomower.session import AutomowerSession from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN @@ -23,15 +25,6 @@ from .entity import AutomowerBaseEntity _LOGGER = logging.getLogger(__name__) -@dataclass(frozen=True, kw_only=True) -class AutomowerNumberEntityDescription(NumberEntityDescription): - """Describes Automower number entity.""" - - exists_fn: Callable[[MowerAttributes], bool] = lambda _: True - value_fn: Callable[[MowerAttributes], int] - set_value_fn: Callable[[AutomowerSession, str, float], Awaitable[Any]] - - @callback def _async_get_cutting_height(data: MowerAttributes) -> int: """Return the cutting height.""" @@ -41,6 +34,39 @@ def _async_get_cutting_height(data: MowerAttributes) -> int: return data.cutting_height +@callback +def _work_area_translation_key(work_area_id: int) -> str: + """Return the translation key.""" + if work_area_id == 0: + return "my_lawn_cutting_height" + return "work_area_cutting_height" + + +async def async_set_work_area_cutting_height( + coordinator: AutomowerDataUpdateCoordinator, + mower_id: str, + cheight: float, + work_area_id: int, +) -> None: + """Set cutting height for work area.""" + await coordinator.api.set_cutting_height_workarea( + mower_id, int(cheight), work_area_id + ) + # As there are no updates from the websocket regarding work area changes, + # we need to wait 5s and then poll the API. + await asyncio.sleep(5) + await coordinator.async_request_refresh() + + +@dataclass(frozen=True, kw_only=True) +class AutomowerNumberEntityDescription(NumberEntityDescription): + """Describes Automower number entity.""" + + exists_fn: Callable[[MowerAttributes], bool] = lambda _: True + value_fn: Callable[[MowerAttributes], int] + set_value_fn: Callable[[AutomowerSession, str, float], Awaitable[Any]] + + NUMBER_TYPES: tuple[AutomowerNumberEntityDescription, ...] = ( AutomowerNumberEntityDescription( key="cutting_height", @@ -58,17 +84,55 @@ NUMBER_TYPES: tuple[AutomowerNumberEntityDescription, ...] = ( ) +@dataclass(frozen=True, kw_only=True) +class AutomowerWorkAreaNumberEntityDescription(NumberEntityDescription): + """Describes Automower work area number entity.""" + + value_fn: Callable[[WorkArea], int] + translation_key_fn: Callable[[int], str] + set_value_fn: Callable[ + [AutomowerDataUpdateCoordinator, str, float, int], Awaitable[Any] + ] + + +WORK_AREA_NUMBER_TYPES: tuple[AutomowerWorkAreaNumberEntityDescription, ...] = ( + AutomowerWorkAreaNumberEntityDescription( + key="cutting_height_work_area", + translation_key_fn=_work_area_translation_key, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda data: data.cutting_height, + set_value_fn=async_set_work_area_cutting_height, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up number platform.""" coordinator: AutomowerDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + entities: list[NumberEntity] = [] + + for mower_id in coordinator.data: + if coordinator.data[mower_id].capabilities.work_areas: + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + entities.extend( + AutomowerWorkAreaNumberEntity( + mower_id, coordinator, description, work_area_id + ) + for description in WORK_AREA_NUMBER_TYPES + for work_area_id in _work_areas + ) + await async_remove_entities(coordinator, hass, entry, mower_id) + entities.extend( AutomowerNumberEntity(mower_id, coordinator, description) for mower_id in coordinator.data for description in NUMBER_TYPES if description.exists_fn(coordinator.data[mower_id]) ) + async_add_entities(entities) class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity): @@ -102,3 +166,74 @@ class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity): raise HomeAssistantError( f"Command couldn't be sent to the command queue: {exception}" ) from exception + + +class AutomowerWorkAreaNumberEntity(AutomowerBaseEntity, NumberEntity): + """Defining the AutomowerWorkAreaNumberEntity with AutomowerWorkAreaNumberEntityDescription.""" + + entity_description: AutomowerWorkAreaNumberEntityDescription + + def __init__( + self, + mower_id: str, + coordinator: AutomowerDataUpdateCoordinator, + description: AutomowerWorkAreaNumberEntityDescription, + work_area_id: int, + ) -> None: + """Set up AutomowerNumberEntity.""" + super().__init__(mower_id, coordinator) + self.entity_description = description + self.work_area_id = work_area_id + self._attr_unique_id = f"{mower_id}_{work_area_id}_{description.key}" + self._attr_translation_placeholders = {"work_area": self.work_area.name} + + @property + def work_area(self) -> WorkArea: + """Get the mower attributes of the current mower.""" + if TYPE_CHECKING: + assert self.mower_attributes.work_areas is not None + return self.mower_attributes.work_areas[self.work_area_id] + + @property + def translation_key(self) -> str: + """Return the translation key of the work area.""" + return self.entity_description.translation_key_fn(self.work_area_id) + + @property + def native_value(self) -> float: + """Return the state of the number.""" + return self.entity_description.value_fn(self.work_area) + + async def async_set_native_value(self, value: float) -> None: + """Change to new number value.""" + try: + await self.entity_description.set_value_fn( + self.coordinator, self.mower_id, value, self.work_area_id + ) + except ApiException as exception: + raise HomeAssistantError( + f"Command couldn't be sent to the command queue: {exception}" + ) from exception + + +async def async_remove_entities( + coordinator: AutomowerDataUpdateCoordinator, + hass: HomeAssistant, + config_entry: ConfigEntry, + mower_id: str, +) -> None: + """Remove deleted work areas from Home Assistant.""" + entity_reg = er.async_get(hass) + work_area_list = [] + _work_areas = coordinator.data[mower_id].work_areas + if _work_areas is not None: + for work_area_id in _work_areas: + uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" + work_area_list.append(uid) + for entity_entry in er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ): + if entity_entry.unique_id.split("_")[0] == mower_id: + if entity_entry.unique_id.endswith("cutting_height_work_area"): + if entity_entry.unique_id not in work_area_list: + entity_reg.async_remove(entity_entry.entity_id) diff --git a/homeassistant/components/husqvarna_automower/strings.json b/homeassistant/components/husqvarna_automower/strings.json index b4c1c97cd68..d8d0c296745 100644 --- a/homeassistant/components/husqvarna_automower/strings.json +++ b/homeassistant/components/husqvarna_automower/strings.json @@ -40,6 +40,12 @@ "number": { "cutting_height": { "name": "Cutting height" + }, + "my_lawn_cutting_height": { + "name": "My lawn cutting height " + }, + "work_area_cutting_height": { + "name": "{work_area} cutting height" } }, "select": { diff --git a/tests/components/husqvarna_automower/fixtures/mower.json b/tests/components/husqvarna_automower/fixtures/mower.json index 1e608e654a6..7d125c6356c 100644 --- a/tests/components/husqvarna_automower/fixtures/mower.json +++ b/tests/components/husqvarna_automower/fixtures/mower.json @@ -14,9 +14,9 @@ }, "capabilities": { "headlights": true, - "workAreas": false, + "workAreas": true, "position": true, - "stayOutZones": false + "stayOutZones": true }, "mower": { "mode": "MAIN_AREA", @@ -68,6 +68,11 @@ "name": "Front lawn", "cuttingHeight": 50 }, + { + "workAreaId": 654321, + "name": "Back lawn", + "cuttingHeight": 25 + }, { "workAreaId": 0, "name": "", diff --git a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr index bdbc0a60490..c604923f67f 100644 --- a/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_diagnostics.ambr @@ -35,8 +35,8 @@ 'capabilities': dict({ 'headlights': True, 'position': True, - 'stay_out_zones': False, - 'work_areas': False, + 'stay_out_zones': True, + 'work_areas': True, }), 'cutting_height': 4, 'headlight': dict({ @@ -97,6 +97,10 @@ 'cutting_height': 50, 'name': 'Front lawn', }), + '654321': dict({ + 'cutting_height': 25, + 'name': 'Back lawn', + }), }), }) # --- diff --git a/tests/components/husqvarna_automower/snapshots/test_number.ambr b/tests/components/husqvarna_automower/snapshots/test_number.ambr index a5479345bd1..4ce5476a555 100644 --- a/tests/components/husqvarna_automower/snapshots/test_number.ambr +++ b/tests/components/husqvarna_automower/snapshots/test_number.ambr @@ -1,4 +1,60 @@ # serializer version: 1 +# name: test_snapshot_number[number.test_mower_1_back_lawn_cutting_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_mower_1_back_lawn_cutting_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Back lawn cutting height', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area_cutting_height', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_654321_cutting_height_work_area', + 'unit_of_measurement': '%', + }) +# --- +# name: test_snapshot_number[number.test_mower_1_back_lawn_cutting_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Back lawn cutting height', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_mower_1_back_lawn_cutting_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '25', + }) +# --- # name: test_snapshot_number[number.test_mower_1_cutting_height-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -54,3 +110,115 @@ 'state': '4', }) # --- +# name: test_snapshot_number[number.test_mower_1_front_lawn_cutting_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_mower_1_front_lawn_cutting_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Front lawn cutting height', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'work_area_cutting_height', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_123456_cutting_height_work_area', + 'unit_of_measurement': '%', + }) +# --- +# name: test_snapshot_number[number.test_mower_1_front_lawn_cutting_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 Front lawn cutting height', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_mower_1_front_lawn_cutting_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_snapshot_number[number.test_mower_1_my_lawn_cutting_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.test_mower_1_my_lawn_cutting_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'My lawn cutting height ', + 'platform': 'husqvarna_automower', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'my_lawn_cutting_height', + 'unique_id': 'c7233734-b219-4287-a173-08e3643f89f0_0_cutting_height_work_area', + 'unit_of_measurement': '%', + }) +# --- +# name: test_snapshot_number[number.test_mower_1_my_lawn_cutting_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Mower 1 My lawn cutting height ', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.test_mower_1_my_lawn_cutting_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- diff --git a/tests/components/husqvarna_automower/test_number.py b/tests/components/husqvarna_automower/test_number.py index b66f1965151..a883ed43e81 100644 --- a/tests/components/husqvarna_automower/test_number.py +++ b/tests/components/husqvarna_automower/test_number.py @@ -3,17 +3,20 @@ from unittest.mock import AsyncMock, patch from aioautomower.exceptions import ApiException +from aioautomower.utils import mower_list_to_dictionary_dataclass import pytest from syrupy import SnapshotAssertion +from homeassistant.components.husqvarna_automower.const import DOMAIN from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from . import setup_integration +from .const import TEST_MOWER_ID -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, load_json_value_fixture, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -51,6 +54,74 @@ async def test_number_commands( assert len(mocked_method.mock_calls) == 2 +async def test_number_workarea_commands( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test number commands.""" + entity_id = "number.test_mower_1_front_lawn_cutting_height" + await setup_integration(hass, mock_config_entry) + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + values[TEST_MOWER_ID].work_areas[123456].cutting_height = 75 + mock_automower_client.get_status.return_value = values + mocked_method = AsyncMock() + setattr(mock_automower_client, "set_cutting_height_workarea", mocked_method) + await hass.services.async_call( + domain="number", + service="set_value", + target={"entity_id": entity_id}, + service_data={"value": "75"}, + blocking=True, + ) + assert len(mocked_method.mock_calls) == 1 + state = hass.states.get(entity_id) + assert state.state is not None + assert state.state == "75" + + mocked_method.side_effect = ApiException("Test error") + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + domain="number", + service="set_value", + target={"entity_id": entity_id}, + service_data={"value": "75"}, + blocking=True, + ) + assert ( + str(exc_info.value) + == "Command couldn't be sent to the command queue: Test error" + ) + assert len(mocked_method.mock_calls) == 2 + + +async def test_workarea_deleted( + hass: HomeAssistant, + mock_automower_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test if work area is deleted after removed.""" + + values = mower_list_to_dictionary_dataclass( + load_json_value_fixture("mower.json", DOMAIN) + ) + await setup_integration(hass, mock_config_entry) + current_entries = len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) + + del values[TEST_MOWER_ID].work_areas[123456] + mock_automower_client.get_status.return_value = values + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert len( + er.async_entries_for_config_entry(entity_registry, mock_config_entry.entry_id) + ) == (current_entries - 1) + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_snapshot_number( hass: HomeAssistant, From 630ddd6a8c8f14d9a7558f3722fdd3c2b8daf8f1 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Mon, 29 Apr 2024 21:26:40 +0200 Subject: [PATCH 149/272] Revert "Remove strict connection" (#116416) --- homeassistant/components/cloud/__init__.py | 8 ++++++++ homeassistant/components/cloud/prefs.py | 11 ++++++++++- homeassistant/components/http/__init__.py | 18 +++++++++++++++--- tests/components/cloud/test_http_api.py | 2 ++ tests/components/cloud/test_init.py | 2 -- tests/components/cloud/test_prefs.py | 1 - .../components/cloud/test_strict_connection.py | 1 - tests/components/http/test_init.py | 2 -- tests/helpers/test_service.py | 5 +++-- tests/scripts/test_check_config.py | 2 ++ 10 files changed, 40 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 13f1d34b5cd..2552fe4bf5c 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -30,6 +30,7 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, + SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -457,3 +458,10 @@ def _setup_services(hass: HomeAssistant, prefs: CloudPreferences) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } + + hass.services.async_register( + DOMAIN, + "create_temporary_strict_connection_url", + create_temporary_strict_connection_url, + supports_response=SupportsResponse.ONLY, + ) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index b4e692d02c4..72207513ca9 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -365,7 +365,16 @@ class CloudPreferences: @property def strict_connection(self) -> http.const.StrictConnectionMode: """Return the strict connection mode.""" - return http.const.StrictConnectionMode.DISABLED + mode = self._prefs.get(PREF_STRICT_CONNECTION) + + if mode is None: + # Set to default value + # We store None in the store as the default value to detect if the user has changed the + # value or not. + mode = http.const.StrictConnectionMode.DISABLED + elif not isinstance(mode, http.const.StrictConnectionMode): + mode = http.const.StrictConnectionMode(mode) + return mode async def get_cloud_user(self) -> str: """Return ID of Home Assistant Cloud system user.""" diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index c783d2f0b71..83601599d88 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -10,7 +10,7 @@ import os import socket import ssl from tempfile import NamedTemporaryFile -from typing import Any, Final, TypedDict, cast +from typing import Any, Final, Required, TypedDict, cast from urllib.parse import quote_plus, urljoin from aiohttp import web @@ -36,6 +36,7 @@ from homeassistant.core import ( HomeAssistant, ServiceCall, ServiceResponse, + SupportsResponse, callback, ) from homeassistant.exceptions import ( @@ -145,6 +146,9 @@ HTTP_SCHEMA: Final = vol.All( [SSL_INTERMEDIATE, SSL_MODERN] ), vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean, + vol.Optional( + CONF_STRICT_CONNECTION, default=StrictConnectionMode.DISABLED + ): vol.Coerce(StrictConnectionMode), } ), ) @@ -168,6 +172,7 @@ class ConfData(TypedDict, total=False): login_attempts_threshold: int ip_ban_enabled: bool ssl_profile: str + strict_connection: Required[StrictConnectionMode] @bind_hass @@ -234,7 +239,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: login_threshold=login_threshold, is_ban_enabled=is_ban_enabled, use_x_frame_options=use_x_frame_options, - strict_connection_non_cloud=StrictConnectionMode.DISABLED, + strict_connection_non_cloud=conf[CONF_STRICT_CONNECTION], ) async def stop_server(event: Event) -> None: @@ -615,7 +620,7 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: if not user.is_admin: raise Unauthorized(context=call.context) - if StrictConnectionMode.DISABLED is StrictConnectionMode.DISABLED: + if conf[CONF_STRICT_CONNECTION] is StrictConnectionMode.DISABLED: raise ServiceValidationError( translation_domain=DOMAIN, translation_key="strict_connection_not_enabled_non_cloud", @@ -647,3 +652,10 @@ def _setup_services(hass: HomeAssistant, conf: ConfData) -> None: "url": f"https://login.home-assistant.io?u={quote_plus(url)}", "direct_url": url, } + + hass.services.async_register( + DOMAIN, + "create_temporary_strict_connection_url", + create_temporary_strict_connection_url, + supports_response=SupportsResponse.ONLY, + ) diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 1e4dc3173e2..d9d2b5c6742 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -915,6 +915,7 @@ async def test_websocket_update_preferences( "google_secure_devices_pin": "1234", "tts_default_voice": ["en-GB", "RyanNeural"], "remote_allow_remote_enable": False, + "strict_connection": StrictConnectionMode.DROP_CONNECTION, } ) response = await client.receive_json() @@ -925,6 +926,7 @@ async def test_websocket_update_preferences( assert cloud.client.prefs.google_secure_devices_pin == "1234" assert cloud.client.prefs.remote_allow_remote_enable is False assert cloud.client.prefs.tts_default_voice == ("en-GB", "RyanNeural") + assert cloud.client.prefs.strict_connection is StrictConnectionMode.DROP_CONNECTION @pytest.mark.parametrize( diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index d917dc12a7c..bc4526975da 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -303,7 +303,6 @@ async def test_cloud_logout( assert cloud.is_logged_in is False -@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -324,7 +323,6 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) -@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/components/cloud/test_prefs.py b/tests/components/cloud/test_prefs.py index a8ce88f5700..57715fe2bdf 100644 --- a/tests/components/cloud/test_prefs.py +++ b/tests/components/cloud/test_prefs.py @@ -181,7 +181,6 @@ async def test_tts_default_voice_legacy_gender( assert cloud.client.prefs.tts_default_voice == (expected_language, voice) -@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize("mode", list(StrictConnectionMode)) async def test_strict_connection_convertion( hass: HomeAssistant, diff --git a/tests/components/cloud/test_strict_connection.py b/tests/components/cloud/test_strict_connection.py index c3329740207..f275bc4d2dd 100644 --- a/tests/components/cloud/test_strict_connection.py +++ b/tests/components/cloud/test_strict_connection.py @@ -226,7 +226,6 @@ async def _guard_page_unauthorized_request( assert await req.text() == await hass.async_add_executor_job(read_guard_page) -@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( "test_func", [ diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 9e576e10f4d..b554737e7b3 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -527,7 +527,6 @@ async def test_logging( assert "GET /api/states/logging.entity" not in caplog.text -@pytest.mark.skip(reason="Remove strict connection config option") async def test_service_create_temporary_strict_connection_url_strict_connection_disabled( hass: HomeAssistant, ) -> None: @@ -545,7 +544,6 @@ async def test_service_create_temporary_strict_connection_url_strict_connection_ ) -@pytest.mark.skip(reason="Remove strict connection config option") @pytest.mark.parametrize( ("mode"), [ diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index c9d92c2f25a..e32768ee33e 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -800,10 +800,11 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert proxy_load_services_files.mock_calls[0][1][1] == unordered( [ await async_get_integration(hass, DOMAIN_GROUP), + await async_get_integration(hass, "http"), # system_health requires http ] ) - assert len(descriptions) == 1 + assert len(descriptions) == 2 assert DOMAIN_GROUP in descriptions assert "description" in descriptions[DOMAIN_GROUP]["reload"] assert "fields" in descriptions[DOMAIN_GROUP]["reload"] @@ -837,7 +838,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: await async_setup_component(hass, DOMAIN_LOGGER, logger_config) descriptions = await service.async_get_all_descriptions(hass) - assert len(descriptions) == 2 + assert len(descriptions) == 3 assert DOMAIN_LOGGER in descriptions assert descriptions[DOMAIN_LOGGER]["set_default_level"]["name"] == "Translated name" assert ( diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 79c64259f8b..76acb2ff678 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest +from homeassistant.components.http.const import StrictConnectionMode from homeassistant.config import YAML_CONFIG_FILE from homeassistant.scripts import check_config @@ -134,6 +135,7 @@ def test_secrets(mock_is_file, event_loop, mock_hass_config_yaml: None) -> None: "login_attempts_threshold": -1, "server_port": 8123, "ssl_profile": "modern", + "strict_connection": StrictConnectionMode.DISABLED, "use_x_frame_options": True, "server_host": ["0.0.0.0", "::"], } From f5700279d34f23c4ccc1676082d58ca39039e84a Mon Sep 17 00:00:00 2001 From: Guy Sie <3367490+GuySie@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:28:47 +0200 Subject: [PATCH 150/272] Add Open Home Foundation link (#116405) --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index be3e18af380..061b44a75f0 100644 --- a/README.rst +++ b/README.rst @@ -7,6 +7,8 @@ Check out `home-assistant.io `__ for `a demo `__, `installation instructions `__, `tutorials `__ and `documentation `__. +This is a project of the `Open Home Foundation `__. + |screenshot-states| Featured integrations @@ -25,4 +27,4 @@ of a component, check the `Home Assistant help section Date: Mon, 29 Apr 2024 21:50:11 +0200 Subject: [PATCH 151/272] Update pytest to 8.2.0 (#116379) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 96263c64712..50ae06c9566 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,7 +28,7 @@ pytest-timeout==2.3.1 pytest-unordered==0.6.0 pytest-picked==0.5.0 pytest-xdist==3.6.1 -pytest==8.1.1 +pytest==8.2.0 requests-mock==1.12.1 respx==0.21.1 syrupy==4.6.1 From 822646749d32f346ffbb6c7ae5ebae06564fcfa0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Apr 2024 04:01:12 +0200 Subject: [PATCH 152/272] Remove entity category "system" check from entity registry (#116412) --- homeassistant/helpers/entity_registry.py | 4 ---- tests/helpers/test_entity_registry.py | 15 +-------------- 2 files changed, 1 insertion(+), 18 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 589b379cf08..c3bd3031750 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -1223,10 +1223,6 @@ class EntityRegistry(BaseRegistry): if data is not None: for entity in data["entities"]: - # We removed this in 2022.5. Remove this check in 2023.1. - if entity["entity_category"] == "system": - entity["entity_category"] = None - try: domain = split_entity_id(entity["entity_id"])[0] _validate_item( diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index bc3b2d6f705..bb0b98c247e 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -398,13 +398,6 @@ async def test_filter_on_load( "unique_id": "disabled-hass", "disabled_by": "hass", # We store the string representation }, - # This entry should have the entity_category reset to None - { - "entity_id": "test.system_entity", - "platform": "super_platform", - "unique_id": "system-entity", - "entity_category": "system", - }, ] }, } @@ -412,13 +405,12 @@ async def test_filter_on_load( await er.async_load(hass) registry = er.async_get(hass) - assert len(registry.entities) == 5 + assert len(registry.entities) == 4 assert set(registry.entities.keys()) == { "test.disabled_hass", "test.disabled_user", "test.named", "test.no_name", - "test.system_entity", } entry_with_name = registry.async_get_or_create( @@ -442,11 +434,6 @@ async def test_filter_on_load( assert entry_disabled_user.disabled assert entry_disabled_user.disabled_by is er.RegistryEntryDisabler.USER - entry_system_category = registry.async_get_or_create( - "test", "system_entity", "system-entity" - ) - assert entry_system_category.entity_category is None - @pytest.mark.parametrize("load_registries", [False]) async def test_load_bad_data( From 7184543f121160bcb9f29b50ceb52e3b508e9335 Mon Sep 17 00:00:00 2001 From: Collin Fair Date: Tue, 30 Apr 2024 01:18:09 -0600 Subject: [PATCH 153/272] Fix stale prayer times from `islamic-prayer-times` (#115683) --- .../islamic_prayer_times/coordinator.py | 83 +++++++++---------- .../islamic_prayer_times/__init__.py | 23 ++++- .../islamic_prayer_times/test_init.py | 49 ++++++++++- .../islamic_prayer_times/test_sensor.py | 31 +++++-- 4 files changed, 131 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 2785f69534c..7005bee3585 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import logging from typing import Any, cast @@ -70,8 +70,8 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """Return the school.""" return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) - def get_new_prayer_times(self) -> dict[str, Any]: - """Fetch prayer times for today.""" + def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: + """Fetch prayer times for the specified date.""" calc = PrayerTimesCalculator( latitude=self.latitude, longitude=self.longitude, @@ -79,7 +79,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim latitudeAdjustmentMethod=self.lat_adj_method, midnightMode=self.midnight_mode, school=self.school, - date=str(dt_util.now().date()), + date=str(for_date), iso8601=True, ) return cast(dict[str, Any], calc.fetch_prayer_times()) @@ -88,51 +88,18 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def async_schedule_future_update(self, midnight_dt: datetime) -> None: """Schedule future update for sensors. - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. + The least surprising behaviour is to load the next day's prayer times only + after the current day's prayers are complete. We will take the fiqhi opinion + that Isha should be prayed before Islamic midnight (which may be before or after 12:00 midnight), + and thus we will switch to the next day's timings at Islamic midnight. - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. + The +1s is to ensure that any automations predicated on the arrival of Islamic midnight will run. """ _LOGGER.debug("Scheduling next update for Islamic prayer times") - now = dt_util.utcnow() - - if now > midnight_dt: - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after the day changes so schedule update for after Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.debug("Next update scheduled for: %s", next_update_at) - self.event_unsub = async_track_point_in_time( - self.hass, self.async_request_update, next_update_at + self.hass, self.async_request_update, midnight_dt + timedelta(seconds=1) ) async def async_request_update(self, _: datetime) -> None: @@ -140,8 +107,34 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim await self.async_request_refresh() async def _async_update_data(self) -> dict[str, datetime]: - """Update sensors with new prayer times.""" - prayer_times = self.get_new_prayer_times() + """Update sensors with new prayer times. + + Prayer time calculations "roll over" at 12:00 midnight - but this does not mean that all prayers + occur within that Gregorian calendar day. For instance Jasper, Alta. sees Isha occur after 00:00 in the summer. + It is similarly possible (albeit less likely) that Fajr occurs before 00:00. + + As such, to ensure that no prayer times are "unreachable" (e.g. we always see the Isha timestamp pass before loading the next day's times), + we calculate 3 days' worth of times (-1, 0, +1 days) and select the appropriate set based on Islamic midnight. + + The calculation is inexpensive, so there is no need to cache it. + """ + + # Zero out the us component to maintain consistent rollover at T+1s + now = dt_util.now().replace(microsecond=0) + yesterday_times = self.get_new_prayer_times((now - timedelta(days=1)).date()) + today_times = self.get_new_prayer_times(now.date()) + tomorrow_times = self.get_new_prayer_times((now + timedelta(days=1)).date()) + + if ( + yesterday_midnight := dt_util.parse_datetime(yesterday_times["Midnight"]) + ) and now <= yesterday_midnight: + prayer_times = yesterday_times + elif ( + tomorrow_midnight := dt_util.parse_datetime(today_times["Midnight"]) + ) and now > tomorrow_midnight: + prayer_times = tomorrow_times + else: + prayer_times = today_times # introduced in prayer-times-calculator 0.0.8 prayer_times.pop("date", None) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 1e6d6815921..522006b0847 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -12,6 +12,17 @@ MOCK_USER_INPUT = { MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} + +PRAYER_TIMES_YESTERDAY = { + "Fajr": "2019-12-31T06:09:00+00:00", + "Sunrise": "2019-12-31T07:24:00+00:00", + "Dhuhr": "2019-12-31T12:29:00+00:00", + "Asr": "2019-12-31T15:31:00+00:00", + "Maghrib": "2019-12-31T17:34:00+00:00", + "Isha": "2019-12-31T18:52:00+00:00", + "Midnight": "2020-01-01T00:44:00+00:00", +} + PRAYER_TIMES = { "Fajr": "2020-01-01T06:10:00+00:00", "Sunrise": "2020-01-01T07:25:00+00:00", @@ -19,7 +30,17 @@ PRAYER_TIMES = { "Asr": "2020-01-01T15:32:00+00:00", "Maghrib": "2020-01-01T17:35:00+00:00", "Isha": "2020-01-01T18:53:00+00:00", - "Midnight": "2020-01-01T00:45:00+00:00", + "Midnight": "2020-01-02T00:45:00+00:00", +} + +PRAYER_TIMES_TOMORROW = { + "Fajr": "2020-01-02T06:11:00+00:00", + "Sunrise": "2020-01-02T07:26:00+00:00", + "Dhuhr": "2020-01-02T12:31:00+00:00", + "Asr": "2020-01-02T15:33:00+00:00", + "Maghrib": "2020-01-02T17:36:00+00:00", + "Isha": "2020-01-02T18:54:00+00:00", + "Midnight": "2020-01-03T00:46:00+00:00", } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index c5d4933e24a..2a2597ef0ce 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -1,5 +1,6 @@ """Tests for Islamic Prayer Times init.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) @@ -76,13 +78,16 @@ async def test_options_listener(hass: HomeAssistant) -> None: ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 1 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 + mock_fetch_prayer_times.reset_mock() hass.config_entries.async_update_entry( entry, options={CONF_CALC_METHOD: "makkah"} ) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 2 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 @pytest.mark.parametrize( @@ -155,3 +160,41 @@ async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: CONF_LONGITUDE: hass.config.longitude, } assert entry.minor_version == 2 + + +async def test_update_scheduling(hass: HomeAssistant) -> None: + """Test that integration schedules update immediately after Islamic midnight.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with ( + patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), + freeze_time(NOW), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) + assert midnight_time + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + mock_fetch_prayer_times.assert_not_called() + + midnight_time += timedelta(seconds=1) + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 1f8d28dfb6f..7bd1a1192ad 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the Islamic prayer times sensor platform.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -8,7 +9,7 @@ import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN from homeassistant.core import HomeAssistant -from . import NOW, PRAYER_TIMES +from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TOMORROW, PRAYER_TIMES_YESTERDAY from tests.common import MockConfigEntry @@ -31,20 +32,38 @@ def set_utc(hass: HomeAssistant) -> None: ("Midnight", "sensor.islamic_prayer_times_midnight_time"), ], ) +# In our example data, Islamic midnight occurs at 00:44 (yesterday's times, occurs today) and 00:45 (today's times, occurs tomorrow), +# hence we check that the times roll over at exactly the desired minute +@pytest.mark.parametrize( + ("offset", "prayer_times"), + [ + (timedelta(days=-1), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44, seconds=1), PRAYER_TIMES), # Rolls over at 00:44 + 1 sec + (timedelta(days=1, minutes=45), PRAYER_TIMES), + ( + timedelta(days=1, minutes=45, seconds=1), # Rolls over at 00:45 + 1 sec + PRAYER_TIMES_TOMORROW, + ), + ], +) async def test_islamic_prayer_times_sensors( - hass: HomeAssistant, key: str, sensor_name: str + hass: HomeAssistant, + key: str, + sensor_name: str, + offset: timedelta, + prayer_times: dict[str, str], ) -> None: """Test minimum Islamic prayer times configuration.""" entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) - with ( patch( "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, + side_effect=(PRAYER_TIMES_YESTERDAY, PRAYER_TIMES, PRAYER_TIMES_TOMORROW), ), - freeze_time(NOW), + freeze_time(NOW + offset), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(sensor_name).state == PRAYER_TIMES[key] + assert hass.states.get(sensor_name).state == prayer_times[key] From b777947978d716c1a8125bc83c6990b8d9489ab6 Mon Sep 17 00:00:00 2001 From: Graham Wetzler Date: Tue, 30 Apr 2024 02:47:06 -0500 Subject: [PATCH 154/272] Bump smart_meter_texas to 0.5.5 (#116321) --- homeassistant/components/smart_meter_texas/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smart_meter_texas/conftest.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json index 1b18bbb2bc9..8bf44fbed15 100644 --- a/homeassistant/components/smart_meter_texas/manifest.json +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", "iot_class": "cloud_polling", "loggers": ["smart_meter_texas"], - "requirements": ["smart-meter-texas==0.4.7"] + "requirements": ["smart-meter-texas==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index a4272a2dd6a..8c7f60f0319 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2563,7 +2563,7 @@ slackclient==2.5.0 slixmpp==1.8.5 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.7 +smart-meter-texas==0.5.5 # homeassistant.components.smhi smhi-pkg==1.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d54dfdc8e3a..e7cda018f15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ simplisafe-python==2024.01.0 slackclient==2.5.0 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.7 +smart-meter-texas==0.5.5 # homeassistant.components.smhi smhi-pkg==1.0.16 diff --git a/tests/components/smart_meter_texas/conftest.py b/tests/components/smart_meter_texas/conftest.py index 04a3344b5cc..d06571fe05e 100644 --- a/tests/components/smart_meter_texas/conftest.py +++ b/tests/components/smart_meter_texas/conftest.py @@ -58,7 +58,7 @@ def mock_connection( """Mock all calls to the API.""" aioclient_mock.get(BASE_URL) - auth_endpoint = f"{BASE_ENDPOINT}{AUTH_ENDPOINT}" + auth_endpoint = AUTH_ENDPOINT if not auth_fail and not auth_timeout: aioclient_mock.post( auth_endpoint, From 59d618bed14862db67a55a2a0ba3cfec75a6f38b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 09:48:58 +0200 Subject: [PATCH 155/272] Fix zoneminder async (#116436) --- homeassistant/components/zoneminder/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 0510ff58d35..b4a406cec4e 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -55,7 +55,7 @@ SET_RUN_STATE_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ZoneMinder component.""" hass.data[DOMAIN] = {} @@ -99,7 +99,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: state_name, ) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, schema=SET_RUN_STATE_SCHEMA ) From fd8287bc1565a6b18e884feb8ad6f5003bf5495e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 09:49:35 +0200 Subject: [PATCH 156/272] Set Synology camera device name as entity name (#109123) --- homeassistant/components/synology_dsm/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 901fcb1d565..1d03fd4f027 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -74,7 +74,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C api_key=SynoSurveillanceStation.CAMERA_API_KEY, key=str(camera_id), camera_id=camera_id, - name=coordinator.data["cameras"][camera_id].name, + name=None, entity_registry_enabled_default=coordinator.data["cameras"][ camera_id ].is_enabled, From 258e20bfc4ed2ebdeaccb354a23cf35e0329aafe Mon Sep 17 00:00:00 2001 From: dontinelli <73341522+dontinelli@users.noreply.github.com> Date: Tue, 30 Apr 2024 10:02:31 +0200 Subject: [PATCH 157/272] Update fyta async_migrate_entry (#116433) Update async_migrate_entry __init__.py --- homeassistant/components/fyta/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fyta/__init__.py b/homeassistant/components/fyta/__init__.py index 205dd97a42f..a62d6435a82 100644 --- a/homeassistant/components/fyta/__init__.py +++ b/homeassistant/components/fyta/__init__.py @@ -71,8 +71,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return False if config_entry.version == 1: - new = {**config_entry.data} if config_entry.minor_version < 2: + new = {**config_entry.data} fyta = FytaConnector( config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD] ) @@ -82,9 +82,9 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> new[CONF_ACCESS_TOKEN] = credentials[CONF_ACCESS_TOKEN] new[CONF_EXPIRATION] = credentials[CONF_EXPIRATION].isoformat() - hass.config_entries.async_update_entry( - config_entry, data=new, minor_version=2, version=1 - ) + hass.config_entries.async_update_entry( + config_entry, data=new, minor_version=2, version=1 + ) _LOGGER.debug( "Migration to version %s.%s successful", From dace9b32de3bc33f0305790eb39b92a28f3c4aa1 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 30 Apr 2024 11:29:43 +0200 Subject: [PATCH 158/272] Store runtime data inside ConfigEntry (#115669) --- homeassistant/components/adguard/__init__.py | 20 +++--- homeassistant/components/adguard/entity.py | 6 +- homeassistant/components/adguard/sensor.py | 9 ++- homeassistant/components/adguard/switch.py | 9 ++- homeassistant/config_entries.py | 7 +- pylint/plugins/hass_enforce_type_hints.py | 14 ++++ tests/pylint/test_enforce_type_hints.py | 76 ++++++++++++++++++++ 7 files changed, 118 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 874a4cae963..d6274659f1d 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from adguardhome import AdGuardHome, AdGuardHomeConnectionError import voluptuous as vol -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -43,6 +43,7 @@ SERVICE_REFRESH_SCHEMA = vol.Schema( ) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] +AdGuardConfigEntry = ConfigEntry["AdGuardData"] @dataclass @@ -53,7 +54,7 @@ class AdGuardData: version: str -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Set up AdGuard Home from a config entry.""" session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL]) adguard = AdGuardHome( @@ -71,7 +72,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except AdGuardHomeConnectionError as exception: raise ConfigEntryNotReady from exception - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AdGuardData(adguard, version) + entry.runtime_data = AdGuardData(adguard, version) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -116,17 +117,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: AdGuardConfigEntry) -> bool: """Unload AdGuard Home config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: + loaded_entries = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + if len(loaded_entries) == 1: + # This is the last loaded instance of AdGuard, deregister any services hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - del hass.data[DOMAIN] return unload_ok diff --git a/homeassistant/components/adguard/entity.py b/homeassistant/components/adguard/entity.py index a4e16f1b995..65d20a4e88c 100644 --- a/homeassistant/components/adguard/entity.py +++ b/homeassistant/components/adguard/entity.py @@ -4,11 +4,11 @@ from __future__ import annotations from adguardhome import AdGuardHomeError -from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry +from homeassistant.config_entries import SOURCE_HASSIO from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import Entity -from . import AdGuardData +from . import AdGuardConfigEntry, AdGuardData from .const import DOMAIN, LOGGER @@ -21,7 +21,7 @@ class AdGuardHomeEntity(Entity): def __init__( self, data: AdGuardData, - entry: ConfigEntry, + entry: AdGuardConfigEntry, ) -> None: """Initialize the AdGuard Home entity.""" self._entry = entry diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index ce112f49531..b2404a88278 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -10,12 +10,11 @@ from typing import Any from adguardhome import AdGuardHome from homeassistant.components.sensor import SensorEntity, SensorEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AdGuardData +from . import AdGuardConfigEntry, AdGuardData from .const import DOMAIN from .entity import AdGuardHomeEntity @@ -85,11 +84,11 @@ SENSORS: tuple[AdGuardHomeEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AdGuardConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home sensor based on a config entry.""" - data: AdGuardData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( [AdGuardHomeSensor(data, entry, description) for description in SENSORS], @@ -105,7 +104,7 @@ class AdGuardHomeSensor(AdGuardHomeEntity, SensorEntity): def __init__( self, data: AdGuardData, - entry: ConfigEntry, + entry: AdGuardConfigEntry, description: AdGuardHomeEntityDescription, ) -> None: """Initialize AdGuard Home sensor.""" diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py index e084ed2f349..3ea4f9d1d93 100644 --- a/homeassistant/components/adguard/switch.py +++ b/homeassistant/components/adguard/switch.py @@ -10,11 +10,10 @@ from typing import Any from adguardhome import AdGuardHome, AdGuardHomeError from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import AdGuardData +from . import AdGuardConfigEntry, AdGuardData from .const import DOMAIN, LOGGER from .entity import AdGuardHomeEntity @@ -79,11 +78,11 @@ SWITCHES: tuple[AdGuardHomeSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AdGuardConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up AdGuard Home switch based on a config entry.""" - data: AdGuardData = hass.data[DOMAIN][entry.entry_id] + data = entry.runtime_data async_add_entities( [AdGuardHomeSwitch(data, entry, description) for description in SWITCHES], @@ -99,7 +98,7 @@ class AdGuardHomeSwitch(AdGuardHomeEntity, SwitchEntity): def __init__( self, data: AdGuardData, - entry: ConfigEntry, + entry: AdGuardConfigEntry, description: AdGuardHomeSwitchEntityDescription, ) -> None: """Initialize AdGuard Home switch.""" diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 619b2a4b48a..123424108fc 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -21,9 +21,10 @@ from functools import cached_property import logging from random import randint from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Self, TypeVar, cast +from typing import TYPE_CHECKING, Any, Generic, Self, cast from async_interrupt import interrupt +from typing_extensions import TypeVar from . import data_entry_flow, loader from .components import persistent_notification @@ -124,6 +125,7 @@ SAVE_DELAY = 1 DISCOVERY_COOLDOWN = 1 +_DataT = TypeVar("_DataT", default=Any) _R = TypeVar("_R") @@ -266,13 +268,14 @@ class ConfigFlowResult(FlowResult, total=False): version: int -class ConfigEntry: +class ConfigEntry(Generic[_DataT]): """Hold a configuration entry.""" entry_id: str domain: str title: str data: MappingProxyType[str, Any] + runtime_data: _DataT options: MappingProxyType[str, Any] unique_id: str | None state: ConfigEntryState diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 7d48fa4b2e3..2f107fb1bf2 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -23,6 +23,10 @@ _COMMON_ARGUMENTS: dict[str, list[str]] = { "hass": ["HomeAssistant", "HomeAssistant | None"] } _PLATFORMS: set[str] = {platform.value for platform in Platform} +_KNOWN_GENERIC_TYPES: set[str] = { + "ConfigEntry", +} +_KNOWN_GENERIC_TYPES_TUPLE = tuple(_KNOWN_GENERIC_TYPES) class _Special(Enum): @@ -2977,6 +2981,16 @@ def _is_valid_type( ): return True + # Allow subscripts or type aliases for generic types + if ( + isinstance(node, nodes.Subscript) + and isinstance(node.value, nodes.Name) + and node.value.name in _KNOWN_GENERIC_TYPES + or isinstance(node, nodes.Name) + and node.name.endswith(_KNOWN_GENERIC_TYPES_TUPLE) + ): + return True + # Name occurs when a namespace is not used, eg. "HomeAssistant" if isinstance(node, nodes.Name) and node.name == expected_type: return True diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 78eb682200a..ad3b7d62be9 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1196,3 +1196,79 @@ def test_pytest_invalid_function( ), ): type_hint_checker.visit_asyncfunctiondef(func_node) + + +@pytest.mark.parametrize( + "entry_annotation", + [ + "ConfigEntry", + "ConfigEntry[AdGuardData]", + "AdGuardConfigEntry", # prefix allowed for type aliases + ], +) +def test_valid_generic( + linter: UnittestLinter, type_hint_checker: BaseChecker, entry_annotation: str +) -> None: + """Ensure valid hints are accepted for generic types.""" + func_node = astroid.extract_node( + f""" + async def async_setup_entry( #@ + hass: HomeAssistant, + entry: {entry_annotation}, + async_add_entities: AddEntitiesCallback, + ) -> None: + pass + """, + "homeassistant.components.pylint_test.notify", + ) + type_hint_checker.visit_module(func_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_asyncfunctiondef(func_node) + + +@pytest.mark.parametrize( + ("entry_annotation", "end_col_offset"), + [ + ("Config", 17), # not generic + ("ConfigEntryXX[Data]", 30), # generic type needs to match exactly + ("ConfigEntryData", 26), # ConfigEntry should be the suffix + ], +) +def test_invalid_generic( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + entry_annotation: str, + end_col_offset: int, +) -> None: + """Ensure invalid hints are rejected for generic types.""" + func_node, entry_node = astroid.extract_node( + f""" + async def async_setup_entry( #@ + hass: HomeAssistant, + entry: {entry_annotation}, #@ + async_add_entities: AddEntitiesCallback, + ) -> None: + pass + """, + "homeassistant.components.pylint_test.notify", + ) + type_hint_checker.visit_module(func_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=entry_node, + args=( + 2, + "ConfigEntry", + "async_setup_entry", + ), + line=4, + col_offset=4, + end_line=4, + end_col_offset=end_col_offset, + ), + ): + type_hint_checker.visit_asyncfunctiondef(func_node) From d84d2109c2ccd5d9d295eeb44a152745d8cd40e2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 12:41:34 +0200 Subject: [PATCH 159/272] Add user id to coordinator name in Withings (#116440) * Add user id to coordinator name in Withings * Add user id to coordinator name in Withings * Fix --- homeassistant/components/withings/coordinator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 19b362dfa0a..0aef11aaa6b 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -45,9 +45,10 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): super().__init__( hass, LOGGER, - name=f"Withings {self.coordinator_name}", + name="", update_interval=self._default_update_interval, ) + self.name = f"Withings {self.config_entry.unique_id} {self.coordinator_name}" self._client = client self.notification_categories: set[NotificationCategory] = set() @@ -63,7 +64,11 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): self, notification_category: NotificationCategory ) -> None: """Update data when webhook is called.""" - LOGGER.debug("Withings webhook triggered for %s", notification_category) + LOGGER.debug( + "Withings webhook triggered for category %s for user %s", + notification_category, + self.config_entry.unique_id, + ) await self.async_request_refresh() async def _async_update_data(self) -> _T: From 8843780aab7469fb3b928da3744fc40ad422f378 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 09:49:35 +0200 Subject: [PATCH 160/272] Set Synology camera device name as entity name (#109123) --- homeassistant/components/synology_dsm/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 901fcb1d565..1d03fd4f027 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -74,7 +74,7 @@ class SynoDSMCamera(SynologyDSMBaseEntity[SynologyDSMCameraUpdateCoordinator], C api_key=SynoSurveillanceStation.CAMERA_API_KEY, key=str(camera_id), camera_id=camera_id, - name=coordinator.data["cameras"][camera_id].name, + name=None, entity_registry_enabled_default=coordinator.data["cameras"][ camera_id ].is_enabled, From 5d9abf9ac52b49c210650626b1c75c8d31479d17 Mon Sep 17 00:00:00 2001 From: Collin Fair Date: Tue, 30 Apr 2024 01:18:09 -0600 Subject: [PATCH 161/272] Fix stale prayer times from `islamic-prayer-times` (#115683) --- .../islamic_prayer_times/coordinator.py | 83 +++++++++---------- .../islamic_prayer_times/__init__.py | 23 ++++- .../islamic_prayer_times/test_init.py | 49 ++++++++++- .../islamic_prayer_times/test_sensor.py | 31 +++++-- 4 files changed, 131 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 2785f69534c..7005bee3585 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta import logging from typing import Any, cast @@ -70,8 +70,8 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim """Return the school.""" return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) - def get_new_prayer_times(self) -> dict[str, Any]: - """Fetch prayer times for today.""" + def get_new_prayer_times(self, for_date: date) -> dict[str, Any]: + """Fetch prayer times for the specified date.""" calc = PrayerTimesCalculator( latitude=self.latitude, longitude=self.longitude, @@ -79,7 +79,7 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim latitudeAdjustmentMethod=self.lat_adj_method, midnightMode=self.midnight_mode, school=self.school, - date=str(dt_util.now().date()), + date=str(for_date), iso8601=True, ) return cast(dict[str, Any], calc.fetch_prayer_times()) @@ -88,51 +88,18 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim def async_schedule_future_update(self, midnight_dt: datetime) -> None: """Schedule future update for sensors. - Midnight is a calculated time. The specifics of the calculation - depends on the method of the prayer time calculation. This calculated - midnight is the time at which the time to pray the Isha prayers have - expired. + The least surprising behaviour is to load the next day's prayer times only + after the current day's prayers are complete. We will take the fiqhi opinion + that Isha should be prayed before Islamic midnight (which may be before or after 12:00 midnight), + and thus we will switch to the next day's timings at Islamic midnight. - Calculated Midnight: The Islamic midnight. - Traditional Midnight: 12:00AM - - Update logic for prayer times: - - If the Calculated Midnight is before the traditional midnight then wait - until the traditional midnight to run the update. This way the day - will have changed over and we don't need to do any fancy calculations. - - If the Calculated Midnight is after the traditional midnight, then wait - until after the calculated Midnight. We don't want to update the prayer - times too early or else the timings might be incorrect. - - Example: - calculated midnight = 11:23PM (before traditional midnight) - Update time: 12:00AM - - calculated midnight = 1:35AM (after traditional midnight) - update time: 1:36AM. + The +1s is to ensure that any automations predicated on the arrival of Islamic midnight will run. """ _LOGGER.debug("Scheduling next update for Islamic prayer times") - now = dt_util.utcnow() - - if now > midnight_dt: - next_update_at = midnight_dt + timedelta(days=1, minutes=1) - _LOGGER.debug( - "Midnight is after the day changes so schedule update for after Midnight the next day" - ) - else: - _LOGGER.debug( - "Midnight is before the day changes so schedule update for the next start of day" - ) - next_update_at = dt_util.start_of_local_day(now + timedelta(days=1)) - - _LOGGER.debug("Next update scheduled for: %s", next_update_at) - self.event_unsub = async_track_point_in_time( - self.hass, self.async_request_update, next_update_at + self.hass, self.async_request_update, midnight_dt + timedelta(seconds=1) ) async def async_request_update(self, _: datetime) -> None: @@ -140,8 +107,34 @@ class IslamicPrayerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, datetim await self.async_request_refresh() async def _async_update_data(self) -> dict[str, datetime]: - """Update sensors with new prayer times.""" - prayer_times = self.get_new_prayer_times() + """Update sensors with new prayer times. + + Prayer time calculations "roll over" at 12:00 midnight - but this does not mean that all prayers + occur within that Gregorian calendar day. For instance Jasper, Alta. sees Isha occur after 00:00 in the summer. + It is similarly possible (albeit less likely) that Fajr occurs before 00:00. + + As such, to ensure that no prayer times are "unreachable" (e.g. we always see the Isha timestamp pass before loading the next day's times), + we calculate 3 days' worth of times (-1, 0, +1 days) and select the appropriate set based on Islamic midnight. + + The calculation is inexpensive, so there is no need to cache it. + """ + + # Zero out the us component to maintain consistent rollover at T+1s + now = dt_util.now().replace(microsecond=0) + yesterday_times = self.get_new_prayer_times((now - timedelta(days=1)).date()) + today_times = self.get_new_prayer_times(now.date()) + tomorrow_times = self.get_new_prayer_times((now + timedelta(days=1)).date()) + + if ( + yesterday_midnight := dt_util.parse_datetime(yesterday_times["Midnight"]) + ) and now <= yesterday_midnight: + prayer_times = yesterday_times + elif ( + tomorrow_midnight := dt_util.parse_datetime(today_times["Midnight"]) + ) and now > tomorrow_midnight: + prayer_times = tomorrow_times + else: + prayer_times = today_times # introduced in prayer-times-calculator 0.0.8 prayer_times.pop("date", None) diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py index 1e6d6815921..522006b0847 100644 --- a/tests/components/islamic_prayer_times/__init__.py +++ b/tests/components/islamic_prayer_times/__init__.py @@ -12,6 +12,17 @@ MOCK_USER_INPUT = { MOCK_CONFIG = {CONF_LATITUDE: 12.34, CONF_LONGITUDE: 23.45} + +PRAYER_TIMES_YESTERDAY = { + "Fajr": "2019-12-31T06:09:00+00:00", + "Sunrise": "2019-12-31T07:24:00+00:00", + "Dhuhr": "2019-12-31T12:29:00+00:00", + "Asr": "2019-12-31T15:31:00+00:00", + "Maghrib": "2019-12-31T17:34:00+00:00", + "Isha": "2019-12-31T18:52:00+00:00", + "Midnight": "2020-01-01T00:44:00+00:00", +} + PRAYER_TIMES = { "Fajr": "2020-01-01T06:10:00+00:00", "Sunrise": "2020-01-01T07:25:00+00:00", @@ -19,7 +30,17 @@ PRAYER_TIMES = { "Asr": "2020-01-01T15:32:00+00:00", "Maghrib": "2020-01-01T17:35:00+00:00", "Isha": "2020-01-01T18:53:00+00:00", - "Midnight": "2020-01-01T00:45:00+00:00", + "Midnight": "2020-01-02T00:45:00+00:00", +} + +PRAYER_TIMES_TOMORROW = { + "Fajr": "2020-01-02T06:11:00+00:00", + "Sunrise": "2020-01-02T07:26:00+00:00", + "Dhuhr": "2020-01-02T12:31:00+00:00", + "Asr": "2020-01-02T15:33:00+00:00", + "Maghrib": "2020-01-02T17:36:00+00:00", + "Isha": "2020-01-02T18:54:00+00:00", + "Midnight": "2020-01-03T00:46:00+00:00", } NOW = datetime(2020, 1, 1, 00, 00, 0, tzinfo=dt_util.UTC) diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index c5d4933e24a..2a2597ef0ce 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -1,5 +1,6 @@ """Tests for Islamic Prayer Times init.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -12,10 +13,11 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.util.dt as dt_util from . import NOW, PRAYER_TIMES -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.fixture(autouse=True) @@ -76,13 +78,16 @@ async def test_options_listener(hass: HomeAssistant) -> None: ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 1 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 + mock_fetch_prayer_times.reset_mock() hass.config_entries.async_update_entry( entry, options={CONF_CALC_METHOD: "makkah"} ) await hass.async_block_till_done() - assert mock_fetch_prayer_times.call_count == 2 + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 @pytest.mark.parametrize( @@ -155,3 +160,41 @@ async def test_migration_from_1_1_to_1_2(hass: HomeAssistant) -> None: CONF_LONGITUDE: hass.config.longitude, } assert entry.minor_version == 2 + + +async def test_update_scheduling(hass: HomeAssistant) -> None: + """Test that integration schedules update immediately after Islamic midnight.""" + entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={}) + entry.add_to_hass(hass) + + with ( + patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ), + freeze_time(NOW), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.LOADED + + with patch( + "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", + return_value=PRAYER_TIMES, + ) as mock_fetch_prayer_times: + midnight_time = dt_util.parse_datetime(PRAYER_TIMES["Midnight"]) + assert midnight_time + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + mock_fetch_prayer_times.assert_not_called() + + midnight_time += timedelta(seconds=1) + with freeze_time(midnight_time): + async_fire_time_changed(hass, midnight_time) + await hass.async_block_till_done() + + # Each scheduling run calls this 3 times (yesterday, today, tomorrow) + assert mock_fetch_prayer_times.call_count == 3 diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py index 1f8d28dfb6f..7bd1a1192ad 100644 --- a/tests/components/islamic_prayer_times/test_sensor.py +++ b/tests/components/islamic_prayer_times/test_sensor.py @@ -1,5 +1,6 @@ """The tests for the Islamic prayer times sensor platform.""" +from datetime import timedelta from unittest.mock import patch from freezegun import freeze_time @@ -8,7 +9,7 @@ import pytest from homeassistant.components.islamic_prayer_times.const import DOMAIN from homeassistant.core import HomeAssistant -from . import NOW, PRAYER_TIMES +from . import NOW, PRAYER_TIMES, PRAYER_TIMES_TOMORROW, PRAYER_TIMES_YESTERDAY from tests.common import MockConfigEntry @@ -31,20 +32,38 @@ def set_utc(hass: HomeAssistant) -> None: ("Midnight", "sensor.islamic_prayer_times_midnight_time"), ], ) +# In our example data, Islamic midnight occurs at 00:44 (yesterday's times, occurs today) and 00:45 (today's times, occurs tomorrow), +# hence we check that the times roll over at exactly the desired minute +@pytest.mark.parametrize( + ("offset", "prayer_times"), + [ + (timedelta(days=-1), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44), PRAYER_TIMES_YESTERDAY), + (timedelta(minutes=44, seconds=1), PRAYER_TIMES), # Rolls over at 00:44 + 1 sec + (timedelta(days=1, minutes=45), PRAYER_TIMES), + ( + timedelta(days=1, minutes=45, seconds=1), # Rolls over at 00:45 + 1 sec + PRAYER_TIMES_TOMORROW, + ), + ], +) async def test_islamic_prayer_times_sensors( - hass: HomeAssistant, key: str, sensor_name: str + hass: HomeAssistant, + key: str, + sensor_name: str, + offset: timedelta, + prayer_times: dict[str, str], ) -> None: """Test minimum Islamic prayer times configuration.""" entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) - with ( patch( "prayer_times_calculator_offline.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, + side_effect=(PRAYER_TIMES_YESTERDAY, PRAYER_TIMES, PRAYER_TIMES_TOMORROW), ), - freeze_time(NOW), + freeze_time(NOW + offset), ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(sensor_name).state == PRAYER_TIMES[key] + assert hass.states.get(sensor_name).state == prayer_times[key] From 3477c81ed1c8146c4a30aa794b1f55651b0cfdf6 Mon Sep 17 00:00:00 2001 From: Graham Wetzler Date: Tue, 30 Apr 2024 02:47:06 -0500 Subject: [PATCH 162/272] Bump smart_meter_texas to 0.5.5 (#116321) --- homeassistant/components/smart_meter_texas/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smart_meter_texas/conftest.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json index 1b18bbb2bc9..8bf44fbed15 100644 --- a/homeassistant/components/smart_meter_texas/manifest.json +++ b/homeassistant/components/smart_meter_texas/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas", "iot_class": "cloud_polling", "loggers": ["smart_meter_texas"], - "requirements": ["smart-meter-texas==0.4.7"] + "requirements": ["smart-meter-texas==0.5.5"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4e788f9aa80..dca62841c08 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2563,7 +2563,7 @@ slackclient==2.5.0 slixmpp==1.8.5 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.7 +smart-meter-texas==0.5.5 # homeassistant.components.smhi smhi-pkg==1.0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 16975128dae..17411a81818 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1982,7 +1982,7 @@ simplisafe-python==2024.01.0 slackclient==2.5.0 # homeassistant.components.smart_meter_texas -smart-meter-texas==0.4.7 +smart-meter-texas==0.5.5 # homeassistant.components.smhi smhi-pkg==1.0.16 diff --git a/tests/components/smart_meter_texas/conftest.py b/tests/components/smart_meter_texas/conftest.py index 04a3344b5cc..d06571fe05e 100644 --- a/tests/components/smart_meter_texas/conftest.py +++ b/tests/components/smart_meter_texas/conftest.py @@ -58,7 +58,7 @@ def mock_connection( """Mock all calls to the API.""" aioclient_mock.get(BASE_URL) - auth_endpoint = f"{BASE_ENDPOINT}{AUTH_ENDPOINT}" + auth_endpoint = AUTH_ENDPOINT if not auth_fail and not auth_timeout: aioclient_mock.post( auth_endpoint, From 1a1dfbd4891f277810a1b53d5d623467089be862 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 29 Apr 2024 20:13:36 +0200 Subject: [PATCH 163/272] Remove semicolon in Modbus (#116399) --- homeassistant/components/modbus/modbus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index bd7eed8235c..a5c0867dedb 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -245,7 +245,7 @@ async def async_modbus_setup( translation_key="deprecated_restart", ) _LOGGER.warning( - "`modbus.restart`: is deprecated and will be removed in version 2024.11" + "`modbus.restart` is deprecated and will be removed in version 2024.11" ) async_dispatcher_send(hass, SIGNAL_START_ENTITY) hub = hub_collect[service.data[ATTR_HUB]] From bd8ded1e55c4b148dc323f5843381ccd761f5724 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 29 Apr 2024 20:19:14 +0200 Subject: [PATCH 164/272] Fix error handling in Shell Command integration (#116409) * raise proper HomeAssistantError on command timeout * raise proper HomeAssistantError on non-utf8 command output * add error translation and test it * Update homeassistant/components/shell_command/strings.json * Update tests/components/shell_command/test_init.py --------- Co-authored-by: G Johansson --- .../components/shell_command/__init__.py | 21 ++++++++++++++----- .../components/shell_command/strings.json | 10 +++++++++ tests/components/shell_command/test_init.py | 12 ++++++++--- 3 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/shell_command/strings.json diff --git a/homeassistant/components/shell_command/__init__.py b/homeassistant/components/shell_command/__init__.py index 95bbb01bcfb..c2c384e39aa 100644 --- a/homeassistant/components/shell_command/__init__.py +++ b/homeassistant/components/shell_command/__init__.py @@ -15,7 +15,7 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.typing import ConfigType from homeassistant.util.json import JsonObjectType @@ -91,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: try: async with asyncio.timeout(COMMAND_TIMEOUT): stdout_data, stderr_data = await process.communicate() - except TimeoutError: + except TimeoutError as err: _LOGGER.error( "Timed out running command: `%s`, after: %ss", cmd, COMMAND_TIMEOUT ) @@ -103,7 +103,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: process._transport.close() # type: ignore[attr-defined] del process - raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="timeout", + translation_placeholders={ + "command": cmd, + "timeout": str(COMMAND_TIMEOUT), + }, + ) from err if stdout_data: _LOGGER.debug( @@ -135,11 +142,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: service_response["stdout"] = stdout_data.decode("utf-8").strip() if stderr_data: service_response["stderr"] = stderr_data.decode("utf-8").strip() - except UnicodeDecodeError: + except UnicodeDecodeError as err: _LOGGER.exception( "Unable to handle non-utf8 output of command: `%s`", cmd ) - raise + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="non_utf8_output", + translation_placeholders={"command": cmd}, + ) from err return service_response return None diff --git a/homeassistant/components/shell_command/strings.json b/homeassistant/components/shell_command/strings.json new file mode 100644 index 00000000000..c87dac15b2d --- /dev/null +++ b/homeassistant/components/shell_command/strings.json @@ -0,0 +1,10 @@ +{ + "exceptions": { + "timeout": { + "message": "Timed out running command: `{command}`, after: {timeout} seconds" + }, + "non_utf8_output": { + "message": "Unable to handle non-utf8 output of command: `{command}`" + } + } +} diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py index 93b06ddf9d8..526ac1643ec 100644 --- a/tests/components/shell_command/test_init.py +++ b/tests/components/shell_command/test_init.py @@ -11,7 +11,7 @@ import pytest from homeassistant.components import shell_command from homeassistant.core import HomeAssistant -from homeassistant.exceptions import TemplateError +from homeassistant.exceptions import HomeAssistantError, TemplateError from homeassistant.setup import async_setup_component @@ -199,7 +199,10 @@ async def test_non_text_stdout_capture( assert not response # Non-text output throws with 'return_response' - with pytest.raises(UnicodeDecodeError): + with pytest.raises( + HomeAssistantError, + match="Unable to handle non-utf8 output of command: `curl -o - https://raw.githubusercontent.com/home-assistant/assets/master/misc/loading-screen.gif`", + ): response = await hass.services.async_call( "shell_command", "output_image", blocking=True, return_response=True ) @@ -258,7 +261,10 @@ async def test_do_not_run_forever( side_effect=mock_create_subprocess_shell, ), ): - with pytest.raises(TimeoutError): + with pytest.raises( + HomeAssistantError, + match="Timed out running command: `mock_sleep 10000`, after: 0.001 seconds", + ): await hass.services.async_call( shell_command.DOMAIN, "test_service", From 5510315b87916402877116dbdf480bc3b95458f2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 09:48:58 +0200 Subject: [PATCH 165/272] Fix zoneminder async (#116436) --- homeassistant/components/zoneminder/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index 0510ff58d35..b4a406cec4e 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -55,7 +55,7 @@ SET_RUN_STATE_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the ZoneMinder component.""" hass.data[DOMAIN] = {} @@ -99,7 +99,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: state_name, ) - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SET_RUN_STATE, set_active_state, schema=SET_RUN_STATE_SCHEMA ) From 7cbb2892c115d97510adfffa429ee5c19c4c8929 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 12:41:34 +0200 Subject: [PATCH 166/272] Add user id to coordinator name in Withings (#116440) * Add user id to coordinator name in Withings * Add user id to coordinator name in Withings * Fix --- homeassistant/components/withings/coordinator.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 19b362dfa0a..0aef11aaa6b 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -45,9 +45,10 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): super().__init__( hass, LOGGER, - name=f"Withings {self.coordinator_name}", + name="", update_interval=self._default_update_interval, ) + self.name = f"Withings {self.config_entry.unique_id} {self.coordinator_name}" self._client = client self.notification_categories: set[NotificationCategory] = set() @@ -63,7 +64,11 @@ class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): self, notification_category: NotificationCategory ) -> None: """Update data when webhook is called.""" - LOGGER.debug("Withings webhook triggered for %s", notification_category) + LOGGER.debug( + "Withings webhook triggered for category %s for user %s", + notification_category, + self.config_entry.unique_id, + ) await self.async_request_refresh() async def _async_update_data(self) -> _T: From 5b7e09b8868d77e6abd5694ee2c729eb23b14d42 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 30 Apr 2024 12:47:51 +0200 Subject: [PATCH 167/272] Bump version to 2024.5.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 35be5835088..3a0d35b8324 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 575063541e9..a04e9fd218a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b3" +version = "2024.5.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From a3942e019b5155893cc0cd446e836f8796fa0951 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:50:35 +0200 Subject: [PATCH 168/272] Use remove_device helper in tests (2/2) (#116442) Use remove_device helper in tests (part 2) --- tests/components/august/test_init.py | 31 +++------------- tests/components/bond/common.py | 14 ------- tests/components/bond/test_init.py | 34 +++++------------ tests/components/fronius/__init__.py | 14 ------- tests/components/fronius/test_init.py | 11 ++---- tests/components/homekit_controller/common.py | 14 ------- .../homekit_controller/test_init.py | 14 +++---- tests/components/ibeacon/test_init.py | 30 +++------------ tests/components/jellyfin/test_init.py | 36 ++++-------------- tests/components/litterrobot/common.py | 14 ------- tests/components/litterrobot/test_init.py | 19 +++------- tests/components/netatmo/test_init.py | 35 +++--------------- tests/components/nexia/test_init.py | 37 ++++--------------- tests/components/onewire/test_init.py | 24 +++--------- tests/components/scrape/test_init.py | 31 +++------------- tests/components/sensibo/test_init.py | 31 +++------------- tests/components/unifiprotect/test_init.py | 37 ++++--------------- 17 files changed, 79 insertions(+), 347 deletions(-) diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 6795491abe3..c62a5b55ac3 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -384,20 +384,6 @@ async def test_load_triggers_ble_discovery( } -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -411,20 +397,13 @@ async def test_device_remove_devices( entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, config_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "remove-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) + assert response["success"] diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 0aff18e6ed1..0fcd2d4a99f 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -19,20 +19,6 @@ from homeassistant.util import utcnow from tests.common import MockConfigEntry, async_fire_time_changed -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 167cd9aa401..3ad589d2d10 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -24,7 +24,6 @@ from .common import ( patch_bond_version, patch_setup_entry, patch_start_bpup, - remove_device, setup_bond_entity, setup_platform, ) @@ -318,45 +317,30 @@ async def test_device_remove_devices( assert entity.unique_id == "test-hub-id_test-device-id" device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, config_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "test-hub-id", "remove-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) + assert response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "wrong-hub-id", "test-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) + assert response["success"] hub_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "test-hub-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), hub_device_entry.id, config_entry.entry_id - ) - is False - ) + response = await client.remove_device(hub_device_entry.id, config_entry.entry_id) + assert not response["success"] async def test_smart_by_bond_v3_firmware(hass: HomeAssistant) -> None: diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 3757abab928..f1630d6cd7e 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__init__.py @@ -126,17 +126,3 @@ async def enable_all_entities(hass, freezer, config_entry_id, time_till_next_upd freezer.tick(time_till_next_update) async_fire_time_changed(hass) await hass.async_block_till_done() - - -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] diff --git a/tests/components/fronius/test_init.py b/tests/components/fronius/test_init.py index 282b2c3fa76..9d570785073 100644 --- a/tests/components/fronius/test_init.py +++ b/tests/components/fronius/test_init.py @@ -12,7 +12,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util -from . import mock_responses, remove_device, setup_fronius_integration +from . import mock_responses, setup_fronius_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -159,11 +159,8 @@ async def test_device_remove_devices( ) inverter_1 = device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) - assert ( - await remove_device( - await hass_ws_client(hass), inverter_1.id, config_entry.entry_id - ) - is True - ) + client = await hass_ws_client(hass) + response = await client.remove_device(inverter_1.id, config_entry.entry_id) + assert response["success"] assert not device_registry.async_get_device(identifiers={(DOMAIN, "12345678")}) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 95bf2530b2d..1360b463e4a 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -399,20 +399,6 @@ async def assert_devices_and_entities_created( assert root_device.via_device_id is None -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - def get_next_aid(): """Get next aid.""" return model_mixin.id_counter + 1 diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 9d2022f6b1c..db7fead9139 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -23,7 +23,6 @@ from homeassistant.util.dt import utcnow from .common import ( Helper, - remove_device, setup_accessories_from_file, setup_test_accessories, setup_test_accessories_with_controller, @@ -99,19 +98,16 @@ async def test_device_remove_devices( entity = entity_registry.entities[ALIVE_DEVICE_ENTITY_ID] live_device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(live_device_entry.id, entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={("homekit_controller:accessory-id", "E9:88:E7:B8:B4:40:aid:1")}, ) - assert ( - await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) - is True - ) + response = await client.remove_device(dead_device_entry.id, entry_id) + assert response["success"] async def test_offline_device_raises(hass: HomeAssistant, controller) -> None: diff --git a/tests/components/ibeacon/test_init.py b/tests/components/ibeacon/test_init.py index 99c45b3dfe7..5a30417efe1 100644 --- a/tests/components/ibeacon/test_init.py +++ b/tests/components/ibeacon/test_init.py @@ -19,20 +19,6 @@ def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -58,17 +44,13 @@ async def test_device_remove_devices( ) }, ) - assert ( - await remove_device(await hass_ws_client(hass), device_entry.id, entry.entry_id) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, entry.entry_id) + assert not response["success"] + dead_device_entry = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "not_seen")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, entry.entry_id) + assert response["success"] diff --git a/tests/components/jellyfin/test_init.py b/tests/components/jellyfin/test_init.py index 6e6a0f7219b..51d7af2ae94 100644 --- a/tests/components/jellyfin/test_init.py +++ b/tests/components/jellyfin/test_init.py @@ -11,23 +11,7 @@ from homeassistant.setup import async_setup_component from . import async_load_json_fixture from tests.common import MockConfigEntry -from tests.typing import MockHAClientWebSocket, WebSocketGenerator - - -async def remove_device( - ws_client: MockHAClientWebSocket, device_id: str, config_entry_id: str -) -> bool: - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 1, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] +from tests.typing import WebSocketGenerator async def test_config_entry_not_ready( @@ -116,19 +100,15 @@ async def test_device_remove_devices( ) }, ) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, mock_config_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, mock_config_entry.entry_id) + assert not response["success"] + old_device_entry = device_registry.async_get_or_create( config_entry_id=mock_config_entry.entry_id, identifiers={(DOMAIN, "OLD-DEVICE-UUID")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), old_device_entry.id, mock_config_entry.entry_id - ) - is True + response = await client.remove_device( + old_device_entry.id, mock_config_entry.entry_id ) + assert response["success"] diff --git a/tests/components/litterrobot/common.py b/tests/components/litterrobot/common.py index cac81aad4ef..8849392b3dd 100644 --- a/tests/components/litterrobot/common.py +++ b/tests/components/litterrobot/common.py @@ -144,17 +144,3 @@ FEEDER_ROBOT_DATA = { } VACUUM_ENTITY_ID = "vacuum.test_litter_box" - - -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] diff --git a/tests/components/litterrobot/test_init.py b/tests/components/litterrobot/test_init.py index 60f359f08f0..f4ad12aeb20 100644 --- a/tests/components/litterrobot/test_init.py +++ b/tests/components/litterrobot/test_init.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from .common import CONFIG, VACUUM_ENTITY_ID, remove_device +from .common import CONFIG, VACUUM_ENTITY_ID from .conftest import setup_integration from tests.common import MockConfigEntry @@ -87,20 +87,13 @@ async def test_device_remove_devices( assert entity.unique_id == "LR3C012345-litter_box" device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, config_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(litterrobot.DOMAIN, "test-serial", "remove-serial")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) + assert response["success"] diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 55af74b3373..8d8dfae9eeb 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -31,7 +31,7 @@ from tests.common import ( async_get_persistent_notifications, ) from tests.components.cloud import mock_cloud -from tests.typing import MockHAClientWebSocket, WebSocketGenerator +from tests.typing import WebSocketGenerator # Fake webhook thermostat mode change to "Max" FAKE_WEBHOOK = { @@ -517,22 +517,6 @@ async def test_devices( assert device_entry == snapshot(name=f"{identifier[0]}-{identifier[1]}") -async def remove_device( - ws_client: MockHAClientWebSocket, device_id: str, config_entry_id: str -) -> bool: - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 1, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -554,20 +538,13 @@ async def test_device_remove_devices( entity = entity_registry.async_get(climate_entity_livingroom) device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, config_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "remove-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, config_entry.entry_id) + assert response["success"] diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index ec84748830a..58ad74c859d 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -20,20 +20,6 @@ async def test_setup_retry_client_os_error(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.SETUP_RETRY -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -47,27 +33,18 @@ async def test_device_remove_devices( entity = registry.entities["sensor.nick_office_temperature"] live_zone_device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), live_zone_device_entry.id, entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(live_zone_device_entry.id, entry_id) + assert not response["success"] entity = registry.entities["sensor.master_suite_humidity"] live_thermostat_device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), live_thermostat_device_entry.id, entry_id - ) - is False - ) + response = await client.remove_device(live_thermostat_device_entry.id, entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "unused")}, ) - assert ( - await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) - is True - ) + response = await client.remove_device(dead_device_entry.id, entry_id) + assert response["success"] diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index 991277d8329..a1a24cd8f83 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -3,7 +3,6 @@ from copy import deepcopy from unittest.mock import MagicMock, patch -import aiohttp from pyownet import protocol import pytest @@ -19,22 +18,6 @@ from . import setup_owproxy_mock_devices from tests.typing import WebSocketGenerator -async def remove_device( - ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str -) -> bool: - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 1, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - @pytest.mark.usefixtures("owproxy_with_connerror") async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Test connection failure raises ConfigEntryNotReady.""" @@ -125,12 +108,15 @@ async def test_registry_cleanup( # Try to remove "10.111111111111" - fails as it is live device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) - assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is False + client = await hass_ws_client(hass) + response = await client.remove_device(device.id, entry_id) + assert not response["success"] assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None # Try to remove "28.111111111111" - succeeds as it is dead device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) - assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is True + response = await client.remove_device(device.id, entry_id) + assert response["success"] assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1 assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py index db1a89e1ce4..09036f213dc 100644 --- a/tests/components/scrape/test_init.py +++ b/tests/components/scrape/test_init.py @@ -129,20 +129,6 @@ async def test_unload_entry(hass: HomeAssistant, loaded_entry: MockConfigEntry) assert loaded_entry.state is ConfigEntryState.NOT_LOADED -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, loaded_entry: MockConfigEntry, @@ -155,20 +141,13 @@ async def test_device_remove_devices( device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, loaded_entry.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, loaded_entry.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=loaded_entry.entry_id, identifiers={(DOMAIN, "remove-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, loaded_entry.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, loaded_entry.entry_id) + assert response["success"] diff --git a/tests/components/sensibo/test_init.py b/tests/components/sensibo/test_init.py index 9ab30edf177..7138da9191f 100644 --- a/tests/components/sensibo/test_init.py +++ b/tests/components/sensibo/test_init.py @@ -152,20 +152,6 @@ async def test_unload_entry(hass: HomeAssistant, get_data: SensiboData) -> None: assert entry.state is ConfigEntryState.NOT_LOADED -async def remove_device(ws_client, device_id, config_entry_id): - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_device_remove_devices( hass: HomeAssistant, load_int: ConfigEntry, @@ -178,20 +164,13 @@ async def test_device_remove_devices( device_registry = dr.async_get(hass) device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device( - await hass_ws_client(hass), device_entry.id, load_int.entry_id - ) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(device_entry.id, load_int.entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=load_int.entry_id, identifiers={(DOMAIN, "remove-device-id")}, ) - assert ( - await remove_device( - await hass_ws_client(hass), dead_device_entry.id, load_int.entry_id - ) - is True - ) + response = await client.remove_device(dead_device_entry.id, load_int.entry_id) + assert response["success"] diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 0e3fd42e28b..69374fd19d4 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import AsyncMock, patch -import aiohttp from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR, Bootstrap, CloudAccount, Light @@ -26,22 +25,6 @@ from tests.common import MockConfigEntry from tests.typing import WebSocketGenerator -async def remove_device( - ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str -) -> bool: - """Remove config entry from a device.""" - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry_id, - "device_id": device_id, - } - ) - response = await ws_client.receive_json() - return response["success"] - - async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture) -> None: """Test working setup of unifiprotect entry.""" @@ -275,19 +258,16 @@ async def test_device_remove_devices( device_registry = dr.async_get(hass) live_device_entry = device_registry.async_get(entity.device_id) - assert ( - await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(live_device_entry.id, entry_id) + assert not response["success"] dead_device_entry = device_registry.async_get_or_create( config_entry_id=entry_id, connections={(dr.CONNECTION_NETWORK_MAC, "e9:88:e7:b8:b4:40")}, ) - assert ( - await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) - is True - ) + response = await client.remove_device(dead_device_entry.id, entry_id) + assert response["success"] async def test_device_remove_devices_nvr( @@ -306,7 +286,6 @@ async def test_device_remove_devices_nvr( device_registry = dr.async_get(hass) live_device_entry = list(device_registry.devices.values())[0] - assert ( - await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) - is False - ) + client = await hass_ws_client(hass) + response = await client.remove_device(live_device_entry.id, entry_id) + assert not response["success"] From ad84ff18eb9a68115b9db787738f540388e7d991 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Apr 2024 12:52:33 +0200 Subject: [PATCH 169/272] Use remove_device helper in tests (1/2) (#116240) * Use remove_device helper in tests * Update test_tag.py * Update test_tag.py --- tests/components/cast/test_media_player.py | 10 +------ .../devolo_home_control/test_init.py | 10 +------ tests/components/fritzbox/test_init.py | 20 ++----------- tests/components/matter/test_init.py | 20 ++----------- tests/components/mqtt/test_device_tracker.py | 10 ++----- tests/components/mqtt/test_device_trigger.py | 20 +++---------- tests/components/mqtt/test_discovery.py | 20 +++---------- tests/components/mqtt/test_init.py | 10 +------ tests/components/mqtt/test_tag.py | 20 +++---------- tests/components/mysensors/test_init.py | 10 +------ tests/components/rfxtrx/test_init.py | 10 +------ tests/components/tasmota/test_init.py | 10 ++----- tests/components/zwave_js/test_init.py | 30 +++---------------- 13 files changed, 29 insertions(+), 171 deletions(-) diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 5481459b715..1d99adb4723 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -813,15 +813,7 @@ async def test_device_registry( chromecast.disconnect.assert_not_called() client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": cast_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, cast_entry.entry_id) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/devolo_home_control/test_init.py b/tests/components/devolo_home_control/test_init.py index 250a31843eb..fa32d67d86c 100644 --- a/tests/components/devolo_home_control/test_init.py +++ b/tests/components/devolo_home_control/test_init.py @@ -83,15 +83,7 @@ async def test_remove_device( assert device_entry client = await hass_ws_client(hass) - await client.send_json( - { - "id": 1, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, entry.entry_id) assert response["success"] assert device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) is None assert hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test") is None diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index 8d7e4249fbd..f0391a03fb7 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -233,30 +233,14 @@ async def test_remove_device( # try to delete good_device ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry.entry_id, - "device_id": good_device.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(good_device.id, entry.entry_id) assert not response["success"] assert response["error"]["code"] == "home_assistant_error" await hass.async_block_till_done() # try to delete orphan_device ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": entry.entry_id, - "device_id": orphan_device.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(orphan_device.id, entry.entry_id) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/matter/test_init.py b/tests/components/matter/test_init.py index 4472e712b20..37eab91894a 100644 --- a/tests/components/matter/test_init.py +++ b/tests/components/matter/test_init.py @@ -634,15 +634,7 @@ async def test_remove_config_entry_device( assert hass.states.get(entity_id) client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] await hass.async_block_till_done() @@ -671,15 +663,7 @@ async def test_remove_config_entry_device_no_node( ) client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 680c48d13c7..4a159b8f9b5 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -274,15 +274,9 @@ async def test_cleanup_device_tracker( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(MQTT_DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index 465e87205fa..1ef80c0b81e 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -986,15 +986,9 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() @@ -1349,15 +1343,9 @@ async def test_cleanup_trigger( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index a00af080bf1..38ce5df25d8 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -843,15 +843,9 @@ async def test_cleanup_device( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() await hass.async_block_till_done() @@ -985,15 +979,9 @@ async def test_cleanup_device_multiple_config_entries( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index cfb8ce7ac04..fc9e596346f 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2850,15 +2850,7 @@ async def test_mqtt_ws_remove_discovered_device( client = await hass_ws_client(hass) mqtt_config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, mqtt_config_entry.entry_id) assert response["success"] # Verify device entry is cleared diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 9a0da989216..9de3b27fc3d 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -419,15 +419,9 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(MQTT_DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry.id, - } + response = await ws_client.remove_device( + device_entry.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] tag_mock.reset_mock() @@ -612,15 +606,9 @@ async def test_cleanup_tag( # Remove MQTT from the device mqtt_config_entry = hass.config_entries.async_entries(MQTT_DOMAIN)[0] - await ws_client.send_json( - { - "id": 6, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mqtt_config_entry.entry_id, - "device_id": device_entry1.id, - } + response = await ws_client.remove_device( + device_entry1.id, mqtt_config_entry.entry_id ) - response = await ws_client.receive_json() assert response["success"] await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index 8c1eeb64b70..7f6ea76d3e1 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -41,15 +41,7 @@ async def test_remove_config_entry_device( assert state client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": config_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] await hass.async_block_till_done() diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py index b969a63a990..43a2a2cdddc 100644 --- a/tests/components/rfxtrx/test_init.py +++ b/tests/components/rfxtrx/test_init.py @@ -112,15 +112,7 @@ async def test_ws_device_remove( # Ask to remove existing device client = await hass_ws_client(hass) - await client.send_json( - { - "id": 5, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": mock_entry.entry_id, - "device_id": device_entry.id, - } - ) - response = await client.receive_json() + response = await client.remove_device(device_entry.id, mock_entry.entry_id) assert response["success"] # Verify device entry is removed diff --git a/tests/components/tasmota/test_init.py b/tests/components/tasmota/test_init.py index 95fb186a46d..72a86fc9986 100644 --- a/tests/components/tasmota/test_init.py +++ b/tests/components/tasmota/test_init.py @@ -168,15 +168,9 @@ async def test_tasmota_ws_remove_discovered_device( client = await hass_ws_client(hass) tasmota_config_entry = hass.config_entries.async_entries(DOMAIN)[0] - await client.send_json( - { - "id": 5, - "config_entry_id": tasmota_config_entry.entry_id, - "type": "config/device_registry/remove_config_entry", - "device_id": device_entry.id, - } + response = await client.remove_device( + device_entry.id, tasmota_config_entry.entry_id ) - response = await client.receive_json() assert response["success"] # Verify device entry is cleared diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 85611262214..66c2c05e530 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -1365,40 +1365,18 @@ async def test_replace_different_node( driver = client.driver client.driver = None - await ws_client.send_json( - { - "id": 1, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": integration.entry_id, - "device_id": hank_device.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(hank_device.id, integration.entry_id) assert not response["success"] client.driver = driver # Attempting to remove the hank device should pass, but removing the multisensor should not - await ws_client.send_json( - { - "id": 2, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": integration.entry_id, - "device_id": hank_device.id, - } - ) - response = await ws_client.receive_json() + response = await ws_client.remove_device(hank_device.id, integration.entry_id) assert response["success"] - await ws_client.send_json( - { - "id": 3, - "type": "config/device_registry/remove_config_entry", - "config_entry_id": integration.entry_id, - "device_id": multisensor_6_device.id, - } + response = await ws_client.remove_device( + multisensor_6_device.id, integration.entry_id ) - response = await ws_client.receive_json() assert not response["success"] From 6f406603a618882e13dcfb9d5bd517ccc002730a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 13:00:11 +0200 Subject: [PATCH 170/272] Store runtime data in entry in Withings (#116439) * Add entry runtime data to Withings * Store runtime data in entry in Withings * Fix * Fix * Update homeassistant/components/withings/coordinator.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --------- Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- homeassistant/components/withings/__init__.py | 23 ++++++++++--------- .../components/withings/binary_sensor.py | 6 ++--- homeassistant/components/withings/calendar.py | 7 +++--- .../components/withings/coordinator.py | 10 +++++--- .../components/withings/diagnostics.py | 8 +++---- homeassistant/components/withings/sensor.py | 7 +++--- 6 files changed, 31 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 0b86a2b5201..2b3d782a055 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -10,7 +10,7 @@ from collections.abc import Awaitable, Callable import contextlib from dataclasses import dataclass, field from datetime import timedelta -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any from aiohttp import ClientError from aiohttp.hdrs import METH_POST @@ -59,6 +59,7 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR, Platform.SENSOR] SUBSCRIBE_DELAY = timedelta(seconds=5) UNSUBSCRIBE_DELAY = timedelta(seconds=1) CONF_CLOUDHOOK_URL = "cloudhook_url" +WithingsConfigEntry = ConfigEntry["WithingsData"] @dataclass(slots=True) @@ -86,7 +87,7 @@ class WithingsData: } -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool: """Set up Withings from a config entry.""" if CONF_WEBHOOK_ID not in entry.data or entry.unique_id is None: new_data = entry.data.copy() @@ -126,7 +127,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in withings_data.coordinators: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = withings_data + entry.runtime_data = withings_data webhook_manager = WithingsWebhookManager(hass, entry) @@ -159,13 +160,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> bool: """Unload Withings config entry.""" webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_subscribe_webhooks(client: WithingsClient, webhook_url: str) -> None: @@ -200,7 +199,7 @@ class WithingsWebhookManager: _webhooks_registered = False _register_lock = asyncio.Lock() - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant, entry: WithingsConfigEntry) -> None: """Initialize webhook manager.""" self.hass = hass self.entry = entry @@ -208,7 +207,7 @@ class WithingsWebhookManager: @property def withings_data(self) -> WithingsData: """Return Withings data.""" - return cast(WithingsData, self.hass.data[DOMAIN][self.entry.entry_id]) + return self.entry.runtime_data async def unregister_webhook( self, @@ -297,7 +296,9 @@ async def async_unsubscribe_webhooks(client: WithingsClient) -> None: ) -async def _async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) -> str: +async def _async_cloudhook_generate_url( + hass: HomeAssistant, entry: WithingsConfigEntry +) -> str: """Generate the full URL for a webhook_id.""" if CONF_CLOUDHOOK_URL not in entry.data: webhook_id = entry.data[CONF_WEBHOOK_ID] @@ -312,7 +313,7 @@ async def _async_cloudhook_generate_url(hass: HomeAssistant, entry: ConfigEntry) return str(entry.data[CONF_CLOUDHOOK_URL]) -async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def async_remove_entry(hass: HomeAssistant, entry: WithingsConfigEntry) -> None: """Cleanup when entry is removed.""" if cloud.async_active_subscription(hass): try: diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 89e2c3227ae..691026ccb9a 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -8,12 +8,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er +from . import WithingsConfigEntry from .const import DOMAIN from .coordinator import WithingsBedPresenceDataUpdateCoordinator from .entity import WithingsEntity @@ -21,11 +21,11 @@ from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WithingsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id].bed_presence_coordinator + coordinator = entry.runtime_data.bed_presence_coordinator ent_reg = er.async_get(hass) diff --git a/homeassistant/components/withings/calendar.py b/homeassistant/components/withings/calendar.py index 3e543e8e9ef..acab0fa5c40 100644 --- a/homeassistant/components/withings/calendar.py +++ b/homeassistant/components/withings/calendar.py @@ -8,25 +8,24 @@ from datetime import datetime from aiowithings import WithingsClient, WorkoutCategory from homeassistant.components.calendar import CalendarEntity, CalendarEvent -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.entity_registry as er -from . import DOMAIN, WithingsData +from . import DOMAIN, WithingsConfigEntry from .coordinator import WithingsWorkoutDataUpdateCoordinator from .entity import WithingsEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WithingsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the calendar platform for entity.""" ent_reg = er.async_get(hass) - withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + withings_data = entry.runtime_data workout_coordinator = withings_data.workout_coordinator diff --git a/homeassistant/components/withings/coordinator.py b/homeassistant/components/withings/coordinator.py index 0aef11aaa6b..cb271fee755 100644 --- a/homeassistant/components/withings/coordinator.py +++ b/homeassistant/components/withings/coordinator.py @@ -1,8 +1,10 @@ """Withings coordinator.""" +from __future__ import annotations + from abc import abstractmethod from datetime import date, datetime, timedelta -from typing import TypeVar +from typing import TYPE_CHECKING, TypeVar from aiowithings import ( Activity, @@ -18,7 +20,6 @@ from aiowithings import ( aggregate_measurements, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -26,6 +27,9 @@ from homeassistant.util import dt as dt_util from .const import LOGGER +if TYPE_CHECKING: + from . import WithingsConfigEntry + _T = TypeVar("_T") UPDATE_INTERVAL = timedelta(minutes=10) @@ -34,7 +38,7 @@ UPDATE_INTERVAL = timedelta(minutes=10) class WithingsDataUpdateCoordinator(DataUpdateCoordinator[_T]): """Base coordinator.""" - config_entry: ConfigEntry + config_entry: WithingsConfigEntry _default_update_interval: timedelta | None = UPDATE_INTERVAL _last_valid_update: datetime | None = None webhooks_connected: bool = False diff --git a/homeassistant/components/withings/diagnostics.py b/homeassistant/components/withings/diagnostics.py index bc51036e6ec..1f74f2be444 100644 --- a/homeassistant/components/withings/diagnostics.py +++ b/homeassistant/components/withings/diagnostics.py @@ -7,16 +7,14 @@ from typing import Any from yarl import URL from homeassistant.components.webhook import async_generate_url as webhook_generate_url -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant -from . import CONF_CLOUDHOOK_URL, WithingsData -from .const import DOMAIN +from . import CONF_CLOUDHOOK_URL, WithingsConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: WithingsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" @@ -26,7 +24,7 @@ async def async_get_config_entry_diagnostics( has_cloudhooks = CONF_CLOUDHOOK_URL in entry.data - withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + withings_data = entry.runtime_data return { "has_valid_external_webhook_url": has_valid_external_webhook_url, diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index a3862485da4..d803481617b 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -22,7 +22,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, Platform, @@ -38,7 +37,7 @@ import homeassistant.helpers.entity_registry as er from homeassistant.helpers.typing import StateType from homeassistant.util import dt as dt_util -from . import WithingsData +from . import WithingsConfigEntry from .const import ( DOMAIN, LOGGER, @@ -619,13 +618,13 @@ def get_current_goals(goals: Goals) -> set[str]: async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: WithingsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the sensor config entry.""" ent_reg = er.async_get(hass) - withings_data: WithingsData = hass.data[DOMAIN][entry.entry_id] + withings_data = entry.runtime_data measurement_coordinator = withings_data.measurement_coordinator From a12301f6965d85ad318b34349e4d522f976e5bbc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 15:07:15 +0200 Subject: [PATCH 171/272] Fix zoneminder async v2 (#116451) --- homeassistant/components/zoneminder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index b4a406cec4e..e87a2b1531d 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -78,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN][host_name] = zm_client try: - success = zm_client.login() and success + success = await hass.async_add_executor_job(zm_client.login) and success except RequestsConnectionError as ex: _LOGGER.error( "ZoneMinder connection failure to %s: %s", From 8291769361bde8393bc374ae7380ba053a3cb9ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Apr 2024 15:55:20 +0200 Subject: [PATCH 172/272] Store runtime data in entry in onewire (#116450) * Store runtime data in entry in onewire * Adjust --- homeassistant/components/onewire/__init__.py | 26 +++++++++---------- .../components/onewire/binary_sensor.py | 18 +++++-------- .../components/onewire/diagnostics.py | 8 +++--- homeassistant/components/onewire/sensor.py | 8 +++--- homeassistant/components/onewire/switch.py | 18 +++++-------- tests/components/onewire/test_init.py | 3 --- 6 files changed, 30 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index 72119915246..73f3374ba97 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -13,12 +13,11 @@ from .const import DOMAIN, PLATFORMS from .onewirehub import CannotConnect, OneWireHub _LOGGER = logging.getLogger(__name__) +OneWireConfigEntry = ConfigEntry[OneWireHub] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: OneWireConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" - hass.data.setdefault(DOMAIN, {}) - onewire_hub = OneWireHub(hass) try: await onewire_hub.initialize(entry) @@ -28,7 +27,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) as exc: raise ConfigEntryNotReady from exc - hass.data[DOMAIN][entry.entry_id] = onewire_hub + entry.runtime_data = onewire_hub await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -38,26 +37,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( - hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry + hass: HomeAssistant, config_entry: OneWireConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - onewire_hub: OneWireHub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = config_entry.runtime_data return not device_entry.identifiers.intersection( (DOMAIN, device.id) for device in onewire_hub.devices or [] ) -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: OneWireConfigEntry +) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def options_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def options_update_listener( + hass: HomeAssistant, entry: OneWireConfigEntry +) -> None: """Handle options update.""" _LOGGER.debug("Configuration options updated, reloading OneWire integration") await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 3c2ca3529cc..82cdb1936f7 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -10,18 +10,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DEVICE_KEYS_0_3, - DEVICE_KEYS_0_7, - DEVICE_KEYS_A_B, - DOMAIN, - READ_MODE_BOOL, -) +from . import OneWireConfigEntry +from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub @@ -95,13 +89,13 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OneWireConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - - entities = await hass.async_add_executor_job(get_entities, onewire_hub) + entities = await hass.async_add_executor_job( + get_entities, config_entry.runtime_data + ) async_add_entities(entities, True) diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index 387553849f3..523bb4e2580 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -6,21 +6,19 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .onewirehub import OneWireHub +from . import OneWireConfigEntry TO_REDACT = {CONF_HOST} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: OneWireConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - onewire_hub: OneWireHub = hass.data[DOMAIN][entry.entry_id] + onewire_hub = entry.runtime_data return { "entry": { diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 3e43df4dddd..b7d7e3ddbe9 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -17,7 +17,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, @@ -29,10 +28,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from . import OneWireConfigEntry from .const import ( DEVICE_KEYS_0_3, DEVICE_KEYS_A_B, - DOMAIN, OPTION_ENTRY_DEVICE_OPTIONS, OPTION_ENTRY_SENSOR_PRECISION, PRECISION_MAPPING_FAMILY_28, @@ -350,13 +349,12 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OneWireConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewire_hub = hass.data[DOMAIN][config_entry.entry_id] entities = await hass.async_add_executor_job( - get_entities, onewire_hub, config_entry.options + get_entities, config_entry.runtime_data, config_entry.options ) async_add_entities(entities, True) diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 94a7d41ab85..11bcbff5970 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -7,18 +7,12 @@ import os from typing import Any from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - DEVICE_KEYS_0_3, - DEVICE_KEYS_0_7, - DEVICE_KEYS_A_B, - DOMAIN, - READ_MODE_BOOL, -) +from . import OneWireConfigEntry +from .const import DEVICE_KEYS_0_3, DEVICE_KEYS_0_7, DEVICE_KEYS_A_B, READ_MODE_BOOL from .onewire_entities import OneWireEntity, OneWireEntityDescription from .onewirehub import OneWireHub @@ -155,13 +149,13 @@ def get_sensor_types( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: OneWireConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - - entities = await hass.async_add_executor_job(get_entities, onewire_hub) + entities = await hass.async_add_executor_job( + get_entities, config_entry.runtime_data + ) async_add_entities(entities, True) diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index a1a24cd8f83..b8ab2fa9ccf 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -26,7 +26,6 @@ async def test_connect_failure(hass: HomeAssistant, config_entry: ConfigEntry) - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) async def test_listing_failure( @@ -40,7 +39,6 @@ async def test_listing_failure( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) @pytest.mark.usefixtures("owproxy") @@ -56,7 +54,6 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - assert not hass.data.get(DOMAIN) async def test_update_options( From feb6cfdd56676b73dd7d36a4358167eb12176c14 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 09:00:06 -0500 Subject: [PATCH 173/272] Add pydantic to skip-binary (#116406) --- .github/workflows/wheels.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 4f652b7a0a1..8edee24a524 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -211,7 +211,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_old-cython.txt" @@ -226,7 +226,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -240,7 +240,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -254,7 +254,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev;nasm" - skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf + skip-binary: aiohttp;charset-normalizer;grpcio;SQLAlchemy;protobuf;pydantic constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" From f9b1b371e94845205ce9aba92a3dfb3bc1ebc0d7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 30 Apr 2024 16:05:49 +0200 Subject: [PATCH 174/272] Remove entity description mixin in NextDNS (#116456) Remove entity description mixin Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- .../components/nextdns/binary_sensor.py | 14 ++++---------- homeassistant/components/nextdns/sensor.py | 16 +++++----------- homeassistant/components/nextdns/switch.py | 13 ++++--------- 3 files changed, 13 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/nextdns/binary_sensor.py b/homeassistant/components/nextdns/binary_sensor.py index 1bb79cf4fce..c4ab58537cd 100644 --- a/homeassistant/components/nextdns/binary_sensor.py +++ b/homeassistant/components/nextdns/binary_sensor.py @@ -25,20 +25,14 @@ from .coordinator import CoordinatorDataT, NextDnsConnectionUpdateCoordinator PARALLEL_UPDATES = 1 -@dataclass(frozen=True) -class NextDnsBinarySensorRequiredKeysMixin(Generic[CoordinatorDataT]): - """Mixin for required keys.""" - - state: Callable[[CoordinatorDataT, str], bool] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class NextDnsBinarySensorEntityDescription( - BinarySensorEntityDescription, - NextDnsBinarySensorRequiredKeysMixin[CoordinatorDataT], + BinarySensorEntityDescription, Generic[CoordinatorDataT] ): """NextDNS binary sensor entity description.""" + state: Callable[[CoordinatorDataT, str], bool] + SENSORS = ( NextDnsBinarySensorEntityDescription[ConnectionStatus]( diff --git a/homeassistant/components/nextdns/sensor.py b/homeassistant/components/nextdns/sensor.py index 3ac2179ed31..a034901aa41 100644 --- a/homeassistant/components/nextdns/sensor.py +++ b/homeassistant/components/nextdns/sensor.py @@ -39,22 +39,16 @@ from .coordinator import CoordinatorDataT, NextDnsUpdateCoordinator PARALLEL_UPDATES = 1 -@dataclass(frozen=True) -class NextDnsSensorRequiredKeysMixin(Generic[CoordinatorDataT]): - """Class for NextDNS entity required keys.""" +@dataclass(frozen=True, kw_only=True) +class NextDnsSensorEntityDescription( + SensorEntityDescription, Generic[CoordinatorDataT] +): + """NextDNS sensor entity description.""" coordinator_type: str value: Callable[[CoordinatorDataT], StateType] -@dataclass(frozen=True) -class NextDnsSensorEntityDescription( - SensorEntityDescription, - NextDnsSensorRequiredKeysMixin[CoordinatorDataT], -): - """NextDNS sensor entity description.""" - - SENSORS: tuple[NextDnsSensorEntityDescription, ...] = ( NextDnsSensorEntityDescription[AnalyticsStatus]( key="all_queries", diff --git a/homeassistant/components/nextdns/switch.py b/homeassistant/components/nextdns/switch.py index dfb796efd8c..a6bbead131e 100644 --- a/homeassistant/components/nextdns/switch.py +++ b/homeassistant/components/nextdns/switch.py @@ -24,19 +24,14 @@ from .coordinator import CoordinatorDataT, NextDnsSettingsUpdateCoordinator PARALLEL_UPDATES = 1 -@dataclass(frozen=True) -class NextDnsSwitchRequiredKeysMixin(Generic[CoordinatorDataT]): - """Class for NextDNS entity required keys.""" - - state: Callable[[CoordinatorDataT], bool] - - -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class NextDnsSwitchEntityDescription( - SwitchEntityDescription, NextDnsSwitchRequiredKeysMixin[CoordinatorDataT] + SwitchEntityDescription, Generic[CoordinatorDataT] ): """NextDNS switch entity description.""" + state: Callable[[CoordinatorDataT], bool] + SWITCHES = ( NextDnsSwitchEntityDescription[Settings]( From 0005f8400dffe7ce9cc5dccf419a1eed1e60fc28 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:10:40 +0200 Subject: [PATCH 175/272] Move Renault service registration (#116459) * Move Renault service registration * Hassfest --- homeassistant/components/renault/__init__.py | 26 +++++++++----------- homeassistant/components/renault/services.py | 6 ----- tests/components/renault/test_services.py | 20 --------------- 3 files changed, 12 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 62425d9c20e..4b7ff8f5648 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -7,10 +7,20 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import ConfigType from .const import CONF_LOCALE, DOMAIN, PLATFORMS from .renault_hub import RenaultHub -from .services import SERVICE_AC_START, setup_services, unload_services +from .services import setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Renault component.""" + setup_services(hass) + return True async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: @@ -36,21 +46,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) - if not hass.services.has_service(DOMAIN, SERVICE_AC_START): - setup_services(hass) - return True async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - if not hass.data[DOMAIN]: - unload_services(hass) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index b49088ddb7d..c274e75b380 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -141,9 +141,3 @@ def setup_services(hass: HomeAssistant) -> None: charge_set_schedules, schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA, ) - - -def unload_services(hass: HomeAssistant) -> None: - """Unload Renault services.""" - for service in SERVICES: - hass.services.async_remove(DOMAIN, service) diff --git a/tests/components/renault/test_services.py b/tests/components/renault/test_services.py index e97988a09f7..a1715a479f2 100644 --- a/tests/components/renault/test_services.py +++ b/tests/components/renault/test_services.py @@ -18,7 +18,6 @@ from homeassistant.components.renault.services import ( SERVICE_AC_CANCEL, SERVICE_AC_START, SERVICE_CHARGE_SET_SCHEDULES, - SERVICES, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -60,25 +59,6 @@ def get_device_id(hass: HomeAssistant) -> str: return device.id -async def test_service_registration( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Test entry setup and unload.""" - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - # Check that all services are registered. - for service in SERVICES: - assert hass.services.has_service(DOMAIN, service) - - # Unload the entry - await hass.config_entries.async_unload(config_entry.entry_id) - - # Check that all services are un-registered. - for service in SERVICES: - assert not hass.services.has_service(DOMAIN, service) - - async def test_service_set_ac_cancel( hass: HomeAssistant, config_entry: ConfigEntry ) -> None: From a4407832088bf88cd86cf754b57053dde9e214b9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 30 Apr 2024 17:39:03 +0200 Subject: [PATCH 176/272] Store runtime data in entry in renault (#116454) --- homeassistant/components/renault/__init__.py | 12 ++++++++---- .../components/renault/binary_sensor.py | 9 +++------ homeassistant/components/renault/button.py | 9 +++------ .../components/renault/device_tracker.py | 9 +++------ homeassistant/components/renault/diagnostics.py | 16 ++++++---------- homeassistant/components/renault/select.py | 9 +++------ homeassistant/components/renault/sensor.py | 9 +++------ homeassistant/components/renault/services.py | 15 +++++++++++---- tests/components/renault/test_init.py | 4 ---- 9 files changed, 40 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/renault/__init__.py b/homeassistant/components/renault/__init__.py index 4b7ff8f5648..1751225f987 100644 --- a/homeassistant/components/renault/__init__.py +++ b/homeassistant/components/renault/__init__.py @@ -15,6 +15,7 @@ from .renault_hub import RenaultHub from .services import setup_services CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) +RenaultConfigEntry = ConfigEntry[RenaultHub] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -23,7 +24,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, config_entry: RenaultConfigEntry +) -> bool: """Load a config entry.""" renault_hub = RenaultHub(hass, config_entry.data[CONF_LOCALE]) try: @@ -36,19 +39,20 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if not login_success: raise ConfigEntryAuthFailed - hass.data.setdefault(DOMAIN, {}) try: await renault_hub.async_initialise(config_entry) except aiohttp.ClientError as exc: raise ConfigEntryNotReady from exc - hass.data[DOMAIN][config_entry.entry_id] = renault_hub + config_entry.runtime_data = renault_hub await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: RenaultConfigEntry +) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/renault/binary_sensor.py b/homeassistant/components/renault/binary_sensor.py index 37e91a1e435..2041499b711 100644 --- a/homeassistant/components/renault/binary_sensor.py +++ b/homeassistant/components/renault/binary_sensor.py @@ -12,14 +12,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription -from .renault_hub import RenaultHub @dataclass(frozen=True, kw_only=True) @@ -35,14 +33,13 @@ class RenaultBinarySensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultBinarySensor] = [ RenaultBinarySensor(vehicle, description) - for vehicle in proxy.vehicles.values() + for vehicle in config_entry.runtime_data.vehicles.values() for description in BINARY_SENSOR_TYPES if description.coordinator in vehicle.coordinators ] diff --git a/homeassistant/components/renault/button.py b/homeassistant/components/renault/button.py index 9a6e1d76df6..d3666388fbb 100644 --- a/homeassistant/components/renault/button.py +++ b/homeassistant/components/renault/button.py @@ -7,13 +7,11 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import RenaultConfigEntry from .entity import RenaultEntity -from .renault_hub import RenaultHub @dataclass(frozen=True, kw_only=True) @@ -26,14 +24,13 @@ class RenaultButtonEntityDescription(ButtonEntityDescription): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultButtonEntity] = [ RenaultButtonEntity(vehicle, description) - for vehicle in proxy.vehicles.values() + for vehicle in config_entry.runtime_data.vehicles.values() for description in BUTTON_TYPES if not description.requires_electricity or vehicle.details.uses_electricity() ] diff --git a/homeassistant/components/renault/device_tracker.py b/homeassistant/components/renault/device_tracker.py index 922173461a0..db889868cae 100644 --- a/homeassistant/components/renault/device_tracker.py +++ b/homeassistant/components/renault/device_tracker.py @@ -5,25 +5,22 @@ from __future__ import annotations from renault_api.kamereon.models import KamereonVehicleLocationData from homeassistant.components.device_tracker import SourceType, TrackerEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription -from .renault_hub import RenaultHub async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultDeviceTracker] = [ RenaultDeviceTracker(vehicle, description) - for vehicle in proxy.vehicles.values() + for vehicle in config_entry.runtime_data.vehicles.values() for description in DEVICE_TRACKER_TYPES if description.coordinator in vehicle.coordinators ] diff --git a/homeassistant/components/renault/diagnostics.py b/homeassistant/components/renault/diagnostics.py index 1234def019e..5d1849f4b20 100644 --- a/homeassistant/components/renault/diagnostics.py +++ b/homeassistant/components/renault/diagnostics.py @@ -5,13 +5,12 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntry -from . import RenaultHub -from .const import CONF_KAMEREON_ACCOUNT_ID, DOMAIN +from . import RenaultConfigEntry +from .const import CONF_KAMEREON_ACCOUNT_ID from .renault_vehicle import RenaultVehicleProxy TO_REDACT = { @@ -27,11 +26,9 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: RenaultConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - renault_hub: RenaultHub = hass.data[DOMAIN][entry.entry_id] - return { "entry": { "title": entry.title, @@ -39,18 +36,17 @@ async def async_get_config_entry_diagnostics( }, "vehicles": [ _get_vehicle_diagnostics(vehicle) - for vehicle in renault_hub.vehicles.values() + for vehicle in entry.runtime_data.vehicles.values() ], } async def async_get_device_diagnostics( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: RenaultConfigEntry, device: DeviceEntry ) -> dict[str, Any]: """Return diagnostics for a device.""" - renault_hub: RenaultHub = hass.data[DOMAIN][entry.entry_id] vin = next(iter(device.identifiers))[1] - vehicle = renault_hub.vehicles[vin] + vehicle = entry.runtime_data.vehicles[vin] return _get_vehicle_diagnostics(vehicle) diff --git a/homeassistant/components/renault/select.py b/homeassistant/components/renault/select.py index eb79e197937..b430da9396e 100644 --- a/homeassistant/components/renault/select.py +++ b/homeassistant/components/renault/select.py @@ -8,14 +8,12 @@ from typing import cast from renault_api.kamereon.models import KamereonVehicleBatteryStatusData from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import RenaultConfigEntry from .entity import RenaultDataEntity, RenaultDataEntityDescription -from .renault_hub import RenaultHub @dataclass(frozen=True, kw_only=True) @@ -29,14 +27,13 @@ class RenaultSelectEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultSelectEntity] = [ RenaultSelectEntity(vehicle, description) - for vehicle in proxy.vehicles.values() + for vehicle in config_entry.runtime_data.vehicles.values() for description in SENSOR_TYPES if description.coordinator in vehicle.coordinators ] diff --git a/homeassistant/components/renault/sensor.py b/homeassistant/components/renault/sensor.py index 352fddb8d8b..5cb4ee333cc 100644 --- a/homeassistant/components/renault/sensor.py +++ b/homeassistant/components/renault/sensor.py @@ -21,7 +21,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, UnitOfEnergy, @@ -36,10 +35,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.util.dt import as_utc, parse_datetime -from .const import DOMAIN +from . import RenaultConfigEntry from .coordinator import T from .entity import RenaultDataEntity, RenaultDataEntityDescription -from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy @@ -58,14 +56,13 @@ class RenaultSensorEntityDescription( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: RenaultConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Renault entities from config entry.""" - proxy: RenaultHub = hass.data[DOMAIN][config_entry.entry_id] entities: list[RenaultSensor[Any]] = [ description.entity_class(vehicle, description) - for vehicle in proxy.vehicles.values() + for vehicle in config_entry.runtime_data.vehicles.values() for description in SENSOR_TYPES if description.coordinator in vehicle.coordinators and (not description.requires_fuel or vehicle.details.uses_fuel()) diff --git a/homeassistant/components/renault/services.py b/homeassistant/components/renault/services.py index c274e75b380..e02a0febdf2 100644 --- a/homeassistant/components/renault/services.py +++ b/homeassistant/components/renault/services.py @@ -9,13 +9,16 @@ from typing import TYPE_CHECKING, Any import voluptuous as vol +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import DOMAIN -from .renault_hub import RenaultHub from .renault_vehicle import RenaultVehicleProxy +if TYPE_CHECKING: + from . import RenaultConfigEntry + LOGGER = logging.getLogger(__name__) ATTR_SCHEDULES = "schedules" @@ -116,9 +119,13 @@ def setup_services(hass: HomeAssistant) -> None: if device_entry is None: raise ValueError(f"Unable to find device with id: {device_id}") - proxy: RenaultHub - for proxy in hass.data[DOMAIN].values(): - for vin, vehicle in proxy.vehicles.items(): + loaded_entries: list[RenaultConfigEntry] = [ + entry + for entry in hass.config_entries.async_entries(DOMAIN) + if entry.state == ConfigEntryState.LOADED + ] + for entry in loaded_entries: + for vin, vehicle in entry.runtime_data.vehicles.items(): if (DOMAIN, vin) in device_entry.identifiers: return vehicle raise ValueError(f"Unable to find vehicle with VIN: {device_entry.identifiers}") diff --git a/tests/components/renault/test_init.py b/tests/components/renault/test_init.py index 6f222c760a7..e6c55f99810 100644 --- a/tests/components/renault/test_init.py +++ b/tests/components/renault/test_init.py @@ -57,7 +57,6 @@ async def test_setup_entry_bad_password( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_ERROR - assert not hass.data.get(DOMAIN) @pytest.mark.parametrize("side_effect", [aiohttp.ClientConnectionError, GigyaException]) @@ -76,7 +75,6 @@ async def test_setup_entry_exception( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) @pytest.mark.usefixtures("patch_renault_account") @@ -95,7 +93,6 @@ async def test_setup_entry_kamereon_exception( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) @pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -111,4 +108,3 @@ async def test_setup_entry_missing_vehicle_details( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.SETUP_RETRY - assert not hass.data.get(DOMAIN) From 1e63665bf259d27a7c4e8623244a69b60e537cc7 Mon Sep 17 00:00:00 2001 From: andarotajo <55669170+andarotajo@users.noreply.github.com> Date: Tue, 30 Apr 2024 18:08:15 +0200 Subject: [PATCH 177/272] Mock dwdwfsapi in all tests that use it (#116414) * Mock dwdwfsapi in all tests * Add mocking for config entries * Fix assertions in init test --- .../dwd_weather_warnings/__init__.py | 15 +++ .../dwd_weather_warnings/conftest.py | 73 +++++++++++++- .../dwd_weather_warnings/test_config_flow.py | 84 +++++++--------- .../dwd_weather_warnings/test_init.py | 99 ++++++++----------- 4 files changed, 164 insertions(+), 107 deletions(-) diff --git a/tests/components/dwd_weather_warnings/__init__.py b/tests/components/dwd_weather_warnings/__init__.py index 03d27d28503..d349f1e7b81 100644 --- a/tests/components/dwd_weather_warnings/__init__.py +++ b/tests/components/dwd_weather_warnings/__init__.py @@ -1 +1,16 @@ """Tests for Deutscher Wetterdienst (DWD) Weather Warnings.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, entry: MockConfigEntry +) -> MockConfigEntry: + """Set up the integration based on the config entry.""" + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/dwd_weather_warnings/conftest.py b/tests/components/dwd_weather_warnings/conftest.py index a09f6cb2fb3..a2932944cc2 100644 --- a/tests/components/dwd_weather_warnings/conftest.py +++ b/tests/components/dwd_weather_warnings/conftest.py @@ -1,10 +1,26 @@ """Configuration for Deutscher Wetterdienst (DWD) Weather Warnings tests.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest +from homeassistant.components.dwd_weather_warnings.const import ( + ADVANCE_WARNING_SENSOR, + CONF_REGION_DEVICE_TRACKER, + CONF_REGION_IDENTIFIER, + CURRENT_WARNING_SENSOR, + DOMAIN, +) +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME + +from tests.common import MockConfigEntry + +MOCK_NAME = "Unit Test" +MOCK_REGION_IDENTIFIER = "807111000" +MOCK_REGION_DEVICE_TRACKER = "device_tracker.test_gps" +MOCK_CONDITIONS = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -14,3 +30,58 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: return_value=True, ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture +def mock_identifier_entry() -> MockConfigEntry: + """Return a mocked config entry with a region identifier.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: MOCK_NAME, + CONF_REGION_IDENTIFIER: MOCK_REGION_IDENTIFIER, + CONF_MONITORED_CONDITIONS: MOCK_CONDITIONS, + }, + ) + + +@pytest.fixture +def mock_tracker_entry() -> MockConfigEntry: + """Return a mocked config entry with a region identifier.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_NAME: MOCK_NAME, + CONF_REGION_DEVICE_TRACKER: MOCK_REGION_DEVICE_TRACKER, + CONF_MONITORED_CONDITIONS: MOCK_CONDITIONS, + }, + ) + + +@pytest.fixture +def mock_dwdwfsapi() -> Generator[MagicMock, None, None]: + """Return a mocked dwdwfsapi API client.""" + with ( + patch( + "homeassistant.components.dwd_weather_warnings.coordinator.DwdWeatherWarningsAPI", + autospec=True, + ) as mock_api, + patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + new=mock_api, + ), + ): + api = mock_api.return_value + api.data_valid = False + api.warncell_id = None + api.warncell_name = None + api.last_update = None + api.current_warning_level = None + api.current_warnings = None + api.expected_warning_level = None + api.expected_warnings = None + api.update = Mock() + api.__bool__ = Mock() + api.__bool__.return_value = True + + yield api diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py index 119c029767a..dfdef0196cb 100644 --- a/tests/components/dwd_weather_warnings/test_config_flow.py +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for Deutscher Wetterdienst (DWD) Weather Warnings config flow.""" from typing import Final -from unittest.mock import patch +from unittest.mock import MagicMock import pytest @@ -29,7 +29,9 @@ DEMO_CONFIG_ENTRY_GPS: Final = { pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_create_entry_region(hass: HomeAssistant) -> None: +async def test_create_entry_region( + hass: HomeAssistant, mock_dwdwfsapi: MagicMock +) -> None: """Test that the full config flow works for a region identifier.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -37,26 +39,20 @@ async def test_create_entry_region(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION - ) + mock_dwdwfsapi.__bool__.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION + ) # Test for invalid region identifier. await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_identifier"} - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION - ) + mock_dwdwfsapi.__bool__.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION + ) # Test for successfully created entry. await hass.async_block_till_done() @@ -68,14 +64,14 @@ async def test_create_entry_region(hass: HomeAssistant) -> None: async def test_create_entry_gps( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_dwdwfsapi: MagicMock ) -> None: """Test that the full config flow works for a device tracker.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM # Test for missing registry entry error. result = await hass.config_entries.flow.async_configure( @@ -83,7 +79,7 @@ async def test_create_entry_gps( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "entity_not_found"} # Test for missing device tracker error. @@ -96,7 +92,7 @@ async def test_create_entry_gps( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "entity_not_found"} # Test for missing attribute error. @@ -111,7 +107,7 @@ async def test_create_entry_gps( ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "attribute_not_found"} # Test for invalid provided identifier. @@ -121,36 +117,32 @@ async def test_create_entry_gps( {ATTR_LATITUDE: "50.180454", ATTR_LONGITUDE: "7.610263"}, ) - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=False, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=DEMO_CONFIG_ENTRY_GPS - ) + mock_dwdwfsapi.__bool__.return_value = False + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY_GPS + ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "invalid_identifier"} # Test for successfully created entry. - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=DEMO_CONFIG_ENTRY_GPS - ) + mock_dwdwfsapi.__bool__.return_value = True + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY_GPS + ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "test_gps" assert result["data"] == { CONF_REGION_DEVICE_TRACKER: registry_entry.id, } -async def test_config_flow_already_configured(hass: HomeAssistant) -> None: +async def test_config_flow_already_configured( + hass: HomeAssistant, mock_dwdwfsapi: MagicMock +) -> None: """Test aborting, if the warncell ID / name is already configured during the config.""" entry = MockConfigEntry( domain=DOMAIN, @@ -167,13 +159,9 @@ async def test_config_flow_already_configured(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert result["type"] is FlowResultType.FORM - with patch( - "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", - return_value=True, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION - ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY_REGION + ) await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT @@ -187,7 +175,7 @@ async def test_config_flow_with_errors(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM # Test error for empty input data. result = await hass.config_entries.flow.async_configure( @@ -195,7 +183,7 @@ async def test_config_flow_with_errors(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "no_identifier"} # Test error for setting both options during configuration. @@ -207,5 +195,5 @@ async def test_config_flow_with_errors(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result["type"] == FlowResultType.FORM + assert result["type"] is FlowResultType.FORM assert result["errors"] == {"base": "ambiguous_identifier"} diff --git a/tests/components/dwd_weather_warnings/test_init.py b/tests/components/dwd_weather_warnings/test_init.py index bfd03b2fdd4..360efc390db 100644 --- a/tests/components/dwd_weather_warnings/test_init.py +++ b/tests/components/dwd_weather_warnings/test_init.py @@ -1,46 +1,28 @@ """Tests for Deutscher Wetterdienst (DWD) Weather Warnings integration.""" -from typing import Final +from unittest.mock import MagicMock from homeassistant.components.dwd_weather_warnings.const import ( - ADVANCE_WARNING_SENSOR, CONF_REGION_DEVICE_TRACKER, - CONF_REGION_IDENTIFIER, - CURRENT_WARNING_SENSOR, DOMAIN, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ( - ATTR_LATITUDE, - ATTR_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - STATE_HOME, -) +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import init_integration + from tests.common import MockConfigEntry -DEMO_IDENTIFIER_CONFIG_ENTRY: Final = { - CONF_NAME: "Unit Test", - CONF_REGION_IDENTIFIER: "807111000", - CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], -} -DEMO_TRACKER_CONFIG_ENTRY: Final = { - CONF_NAME: "Unit Test", - CONF_REGION_DEVICE_TRACKER: "device_tracker.test_gps", - CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], -} - - -async def test_load_unload_entry(hass: HomeAssistant) -> None: +async def test_load_unload_entry( + hass: HomeAssistant, + mock_identifier_entry: MockConfigEntry, + mock_dwdwfsapi: MagicMock, +) -> None: """Test loading and unloading the integration with a region identifier based entry.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_IDENTIFIER_CONFIG_ENTRY) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() + entry = await init_integration(hass, mock_identifier_entry) assert entry.state is ConfigEntryState.LOADED assert entry.entry_id in hass.data[DOMAIN] @@ -52,66 +34,67 @@ async def test_load_unload_entry(hass: HomeAssistant) -> None: assert entry.entry_id not in hass.data[DOMAIN] -async def test_load_invalid_registry_entry(hass: HomeAssistant) -> None: +async def test_load_invalid_registry_entry( + hass: HomeAssistant, mock_tracker_entry: MockConfigEntry +) -> None: """Test loading the integration with an invalid registry entry ID.""" - INVALID_DATA = DEMO_TRACKER_CONFIG_ENTRY.copy() + INVALID_DATA = mock_tracker_entry.data.copy() INVALID_DATA[CONF_REGION_DEVICE_TRACKER] = "invalid_registry_id" - entry = MockConfigEntry(domain=DOMAIN, data=INVALID_DATA) - entry.add_to_hass(hass) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.SETUP_RETRY + entry = await init_integration( + hass, MockConfigEntry(domain=DOMAIN, data=INVALID_DATA) + ) + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_load_missing_device_tracker(hass: HomeAssistant) -> None: +async def test_load_missing_device_tracker( + hass: HomeAssistant, mock_tracker_entry: MockConfigEntry +) -> None: """Test loading the integration with a missing device tracker.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_TRACKER_CONFIG_ENTRY) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.SETUP_RETRY + entry = await init_integration(hass, mock_tracker_entry) + assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_load_missing_required_attribute(hass: HomeAssistant) -> None: +async def test_load_missing_required_attribute( + hass: HomeAssistant, mock_tracker_entry: MockConfigEntry +) -> None: """Test loading the integration with a device tracker missing a required attribute.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_TRACKER_CONFIG_ENTRY) - entry.add_to_hass(hass) - + mock_tracker_entry.add_to_hass(hass) hass.states.async_set( - DEMO_TRACKER_CONFIG_ENTRY[CONF_REGION_DEVICE_TRACKER], + mock_tracker_entry.data[CONF_REGION_DEVICE_TRACKER], STATE_HOME, {ATTR_LONGITUDE: "7.610263"}, ) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_tracker_entry.entry_id) await hass.async_block_till_done() - assert entry.state == ConfigEntryState.SETUP_RETRY + assert mock_tracker_entry.state is ConfigEntryState.SETUP_RETRY async def test_load_valid_device_tracker( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_tracker_entry: MockConfigEntry, + mock_dwdwfsapi: MagicMock, ) -> None: """Test loading the integration with a valid device tracker based entry.""" - entry = MockConfigEntry(domain=DOMAIN, data=DEMO_TRACKER_CONFIG_ENTRY) - entry.add_to_hass(hass) + mock_tracker_entry.add_to_hass(hass) entity_registry.async_get_or_create( "device_tracker", - entry.domain, + mock_tracker_entry.domain, "uuid", suggested_object_id="test_gps", - config_entry=entry, + config_entry=mock_tracker_entry, ) hass.states.async_set( - DEMO_TRACKER_CONFIG_ENTRY[CONF_REGION_DEVICE_TRACKER], + mock_tracker_entry.data[CONF_REGION_DEVICE_TRACKER], STATE_HOME, {ATTR_LATITUDE: "50.180454", ATTR_LONGITUDE: "7.610263"}, ) - await hass.config_entries.async_setup(entry.entry_id) + await hass.config_entries.async_setup(mock_tracker_entry.entry_id) await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED - assert entry.entry_id in hass.data[DOMAIN] + assert mock_tracker_entry.state is ConfigEntryState.LOADED + assert mock_tracker_entry.entry_id in hass.data[DOMAIN] From ff104f54b5fe613389c8d331e4067a8731f627f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 11:43:58 -0500 Subject: [PATCH 178/272] Small performance improvements to ingress forwarding (#116457) --- homeassistant/components/hassio/ingress.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index ed6e47145dd..2bd1caf8977 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -177,11 +177,13 @@ class HassIOIngress(HomeAssistantView): if maybe_content_type := result.headers.get(hdrs.CONTENT_TYPE): content_type: str = (maybe_content_type.partition(";"))[0].strip() else: - content_type = result.content_type + # default value according to RFC 2616 + content_type = "application/octet-stream" + # Simple request if result.status in (204, 304) or ( content_length is not UNDEFINED - and (content_length_int := int(content_length or 0)) + and (content_length_int := int(content_length)) <= MAX_SIMPLE_RESPONSE_SIZE ): # Return Response @@ -194,17 +196,17 @@ class HassIOIngress(HomeAssistantView): zlib_executor_size=32768, ) if content_length_int > MIN_COMPRESSED_SIZE and should_compress( - content_type or simple_response.content_type + content_type ): simple_response.enable_compression() return simple_response # Stream response response = web.StreamResponse(status=result.status, headers=headers) - response.content_type = result.content_type + response.content_type = content_type try: - if should_compress(response.content_type): + if should_compress(content_type): response.enable_compression() await response.prepare(request) # In testing iter_chunked, iter_any, and iter_chunks: From 6be2b334d81a01c59505b3ce8296b40a583dc956 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 11:44:25 -0500 Subject: [PATCH 179/272] Avoid netloc ipaddress re-encoding to construct ingress urls (#116431) --- homeassistant/components/hassio/ingress.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 2bd1caf8977..3a3eb0e945c 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -67,15 +67,15 @@ class HassIOIngress(HomeAssistantView): """Initialize a Hass.io ingress view.""" self._host = host self._websession = websession + self._url = URL(f"http://{host}") @lru_cache def _create_url(self, token: str, path: str) -> URL: """Create URL to service.""" base_path = f"/ingress/{token}/" - url = f"http://{self._host}{base_path}{quote(path)}" try: - target_url = URL(url) + target_url = self._url.join(URL(f"{base_path}{quote(path)}")) except ValueError as err: raise HTTPBadRequest from err From fbe1781ebcd4e9e01c32dd410735a1cc7c56944c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 11:53:55 -0500 Subject: [PATCH 180/272] Bump bluetooth-adapters to 0.19.1 (#116465) --- 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 ed1e11d8ddd..4bb84ab6dc3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.5.0", - "bluetooth-adapters==0.19.0", + "bluetooth-adapters==0.19.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a2eb0f1254c..4ba38346e83 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ awesomeversion==24.2.0 bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8c7f60f0319..ad4c422db8c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e7cda018f15..075f7ac573a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -494,7 +494,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From 9995207817ea85c2c86fa2cce37a2a914d4abfe7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 12:02:28 -0500 Subject: [PATCH 181/272] Avoid re-encoding the message id as bytes for every event/state change (#116460) --- .../components/websocket_api/commands.py | 21 +++++++++++-------- .../components/websocket_api/messages.py | 10 +++++---- .../components/websocket_api/test_messages.py | 16 +++++++------- 3 files changed, 26 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 54539158148..0f52685ca2d 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -105,7 +105,7 @@ def pong_message(iden: int) -> dict[str, Any]: def _forward_events_check_permissions( send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], user: User, - msg_id: int, + message_id_as_bytes: bytes, event: Event, ) -> None: """Forward state changed events to websocket.""" @@ -118,17 +118,17 @@ def _forward_events_check_permissions( and not permissions.check_entity(event.data["entity_id"], POLICY_READ) ): return - send_message(messages.cached_event_message(msg_id, event)) + send_message(messages.cached_event_message(message_id_as_bytes, event)) @callback def _forward_events_unconditional( send_message: Callable[[bytes | str | dict[str, Any] | Callable[[], str]], None], - msg_id: int, + message_id_as_bytes: bytes, event: Event, ) -> None: """Forward events to websocket.""" - send_message(messages.cached_event_message(msg_id, event)) + send_message(messages.cached_event_message(message_id_as_bytes, event)) @callback @@ -152,16 +152,18 @@ def handle_subscribe_events( ) raise Unauthorized(user_id=connection.user.id) + message_id_as_bytes = str(msg["id"]).encode() + if event_type == EVENT_STATE_CHANGED: forward_events = partial( _forward_events_check_permissions, connection.send_message, connection.user, - msg["id"], + message_id_as_bytes, ) else: forward_events = partial( - _forward_events_unconditional, connection.send_message, msg["id"] + _forward_events_unconditional, connection.send_message, message_id_as_bytes ) connection.subscriptions[msg["id"]] = hass.bus.async_listen( @@ -366,7 +368,7 @@ def _forward_entity_changes( send_message: Callable[[str | bytes | dict[str, Any] | Callable[[], str]], None], entity_ids: set[str], user: User, - msg_id: int, + message_id_as_bytes: bytes, event: Event[EventStateChangedData], ) -> None: """Forward entity state changed events to websocket.""" @@ -382,7 +384,7 @@ def _forward_entity_changes( and not permissions.check_entity(event.data["entity_id"], POLICY_READ) ): return - send_message(messages.cached_state_diff_message(msg_id, event)) + send_message(messages.cached_state_diff_message(message_id_as_bytes, event)) @callback @@ -401,6 +403,7 @@ def handle_subscribe_entities( # state changed events or we will introduce a race condition # where some states are missed states = _async_get_allowed_states(hass, connection) + message_id_as_bytes = str(msg["id"]).encode() connection.subscriptions[msg["id"]] = hass.bus.async_listen( EVENT_STATE_CHANGED, partial( @@ -408,7 +411,7 @@ def handle_subscribe_entities( connection.send_message, entity_ids, connection.user, - msg["id"], + message_id_as_bytes, ), ) connection.send_result(msg["id"]) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 75a9c9999d4..98db92dfef7 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -109,7 +109,7 @@ def event_message(iden: int, event: Any) -> dict[str, Any]: return {"id": iden, "type": "event", "event": event} -def cached_event_message(iden: int, event: Event) -> bytes: +def cached_event_message(message_id_as_bytes: bytes, event: Event) -> bytes: """Return an event message. Serialize to json once per message. @@ -122,7 +122,7 @@ def cached_event_message(iden: int, event: Event) -> bytes: ( _partial_cached_event_message(event)[:-1], b',"id":', - str(iden).encode(), + message_id_as_bytes, b"}", ) ) @@ -141,7 +141,9 @@ def _partial_cached_event_message(event: Event) -> bytes: ) -def cached_state_diff_message(iden: int, event: Event[EventStateChangedData]) -> bytes: +def cached_state_diff_message( + message_id_as_bytes: bytes, event: Event[EventStateChangedData] +) -> bytes: """Return an event message. Serialize to json once per message. @@ -154,7 +156,7 @@ def cached_state_diff_message(iden: int, event: Event[EventStateChangedData]) -> ( _partial_cached_state_diff_message(event)[:-1], b',"id":', - str(iden).encode(), + message_id_as_bytes, b"}", ) ) diff --git a/tests/components/websocket_api/test_messages.py b/tests/components/websocket_api/test_messages.py index 350aed8b5f7..6294b6a2628 100644 --- a/tests/components/websocket_api/test_messages.py +++ b/tests/components/websocket_api/test_messages.py @@ -32,11 +32,11 @@ async def test_cached_event_message(hass: HomeAssistant) -> None: assert len(events) == 2 lru_event_cache.cache_clear() - msg0 = cached_event_message(2, events[0]) - assert msg0 == cached_event_message(2, events[0]) + msg0 = cached_event_message(b"2", events[0]) + assert msg0 == cached_event_message(b"2", events[0]) - msg1 = cached_event_message(2, events[1]) - assert msg1 == cached_event_message(2, events[1]) + msg1 = cached_event_message(b"2", events[1]) + assert msg1 == cached_event_message(b"2", events[1]) assert msg0 != msg1 @@ -45,7 +45,7 @@ async def test_cached_event_message(hass: HomeAssistant) -> None: assert cache_info.misses == 2 assert cache_info.currsize == 2 - cached_event_message(2, events[1]) + cached_event_message(b"2", events[1]) cache_info = lru_event_cache.cache_info() assert cache_info.hits == 3 assert cache_info.misses == 2 @@ -70,9 +70,9 @@ async def test_cached_event_message_with_different_idens(hass: HomeAssistant) -> lru_event_cache.cache_clear() - msg0 = cached_event_message(2, events[0]) - msg1 = cached_event_message(3, events[0]) - msg2 = cached_event_message(4, events[0]) + msg0 = cached_event_message(b"2", events[0]) + msg1 = cached_event_message(b"3", events[0]) + msg2 = cached_event_message(b"4", events[0]) assert msg0 != msg1 assert msg0 != msg2 From c7a84b1c7bcb9b5a424c25898d63331f140d5c5e Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 30 Apr 2024 14:13:56 -0400 Subject: [PATCH 182/272] Bump pydantic constraint (#116401) Co-authored-by: J. Nick Koston --- homeassistant/components/bang_olufsen/media_player.py | 4 +--- homeassistant/components/unifiprotect/camera.py | 2 +- homeassistant/components/unifiprotect/data.py | 2 +- homeassistant/components/unifiprotect/entity.py | 8 ++++---- homeassistant/package_constraints.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 2 +- 7 files changed, 10 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bang_olufsen/media_player.py b/homeassistant/components/bang_olufsen/media_player.py index 9f55790d711..935c057efc8 100644 --- a/homeassistant/components/bang_olufsen/media_player.py +++ b/homeassistant/components/bang_olufsen/media_player.py @@ -363,9 +363,7 @@ class BangOlufsenMediaPlayer(BangOlufsenEntity, MediaPlayerEntity): def is_volume_muted(self) -> bool | None: """Boolean if volume is currently muted.""" if self._volume.muted and self._volume.muted.muted: - # The any return here is side effect of pydantic v2 compatibility - # This will be fixed in the future. - return self._volume.muted.muted # type: ignore[no-any-return] + return self._volume.muted.muted return None @property diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 1e99bdff541..8e10c09872b 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -155,7 +155,7 @@ async def async_setup_entry( @callback def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: if not isinstance(device, UFPCamera): - return # type: ignore[unreachable] + return entities = _async_camera_entities(hass, entry, data, ufp_device=device) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 55ddf91d3cb..6c5a1472015 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -226,7 +226,7 @@ class ProtectData: self._async_update_device(obj, message.changed_data) # trigger updates for camera that the event references - elif isinstance(obj, Event): # type: ignore[unreachable] + elif isinstance(obj, Event): if _LOGGER.isEnabledFor(logging.DEBUG): log_event(obj) if obj.type is EventType.DEVICE_ADOPTED: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 932cc75b9d0..49478ce0582 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -267,7 +267,7 @@ class ProtectDeviceEntity(Entity): return (self._attr_available,) @callback - def _async_updated_event(self, device: ProtectModelWithId) -> None: + def _async_updated_event(self, device: ProtectAdoptableDeviceModel | NVR) -> None: """When device is updated from Protect.""" previous_attrs = self._async_get_state_attrs() @@ -275,7 +275,7 @@ class ProtectDeviceEntity(Entity): current_attrs = self._async_get_state_attrs() if previous_attrs != current_attrs: if _LOGGER.isEnabledFor(logging.DEBUG): - device_name = device.name + device_name = device.name or "" if hasattr(self, "entity_description") and self.entity_description.name: device_name += f" {self.entity_description.name}" @@ -302,7 +302,7 @@ class ProtectNVREntity(ProtectDeviceEntity): """Base class for unifi protect entities.""" # separate subclass on purpose - device: NVR + device: NVR # type: ignore[assignment] def __init__( self, @@ -311,7 +311,7 @@ class ProtectNVREntity(ProtectDeviceEntity): description: EntityDescription | None = None, ) -> None: """Initialize the entity.""" - super().__init__(entry, device, description) + super().__init__(entry, device, description) # type: ignore[arg-type] @callback def _async_set_device_info(self) -> None: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ba38346e83..e9705b40bd0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -133,7 +133,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.12 +pydantic==1.10.15 # Breaks asyncio # https://github.com/pubnub/python/issues/130 diff --git a/requirements_test.txt b/requirements_test.txt index 50ae06c9566..e932e9ff6ab 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.5.0 mock-open==1.4.0 mypy==1.10.0 pre-commit==3.7.0 -pydantic==1.10.12 +pydantic==1.10.15 pylint==3.1.0 pylint-per-file-ignores==1.3.2 pipdeptree==2.19.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a5db9997d9d..602a9fe934b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -155,7 +155,7 @@ backoff>=2.0 # Required to avoid breaking (#101042). # v2 has breaking changes (#99218). -pydantic==1.10.12 +pydantic==1.10.15 # Breaks asyncio # https://github.com/pubnub/python/issues/130 From 23a8b29bfe8b3aadc6624ff88d5de150196986ac Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 30 Apr 2024 21:31:52 +0200 Subject: [PATCH 183/272] Bring sensibo to full coverage (again) (#116469) --- tests/components/sensibo/test_climate.py | 62 ++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 061e31f9771..55d404b8331 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -121,6 +121,32 @@ async def test_climate_fan( state1 = hass.states.get("climate.hallway") assert state1.attributes["fan_mode"] == "high" + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "fan_modes", + ["quiet", "low", "medium", "not_in_ha"], + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "fan_modes_translated", + { + "low": "low", + "medium": "medium", + "quiet": "quiet", + "not_in_ha": "not_in_ha", + }, + ) + with pytest.raises( + HomeAssistantError, + match="Climate fan mode not_in_ha is not supported by the integration", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_FAN_MODE: "not_in_ha"}, + blocking=True, + ) + with ( patch( "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", @@ -194,6 +220,42 @@ async def test_climate_swing( state1 = hass.states.get("climate.hallway") assert state1.attributes["swing_mode"] == "stopped" + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "swing_modes", + ["stopped", "fixedtop", "fixedmiddletop", "not_in_ha"], + ) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "swing_modes_translated", + { + "fixedmiddletop": "fixedMiddleTop", + "fixedtop": "fixedTop", + "stopped": "stopped", + "not_in_ha": "not_in_ha", + }, + ) + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt_util.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, + match="Climate swing mode not_in_ha is not supported by the integration", + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_SWING_MODE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_SWING_MODE: "not_in_ha"}, + blocking=True, + ) + with ( patch( "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", From 6c446b4e5977c74318e78538dc696a23940f639a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:31:09 -0500 Subject: [PATCH 184/272] Fix local_todo blocking the event loop (#116473) --- homeassistant/components/local_todo/todo.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 5b25abf8e21..ccd3d8db759 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util import dt as dt_util from .const import CONF_TODO_LIST_NAME, DOMAIN @@ -67,9 +68,16 @@ async def async_setup_entry( ) -> None: """Set up the local_todo todo platform.""" - store = hass.data[DOMAIN][config_entry.entry_id] + store: LocalTodoListStore = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() - calendar = IcsCalendarStream.calendar_from_ics(ics) + + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # calendar_from_ics will dynamically load packages + # the first time it is called, so we need to do it + # in a separate thread to avoid blocking the event loop + calendar: Calendar = await hass.async_add_import_executor_job( + IcsCalendarStream.calendar_from_ics, ics + ) migrated = _migrate_calendar(calendar) calendar.prodid = PRODID From 2e9b1916c0d0e904476183811e5c1720a5391e71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:31:40 -0500 Subject: [PATCH 185/272] Ensure MQTT resubscribes happen before birth message (#116471) --- homeassistant/components/mqtt/client.py | 56 +++++++++++++++---------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index d094776efe0..99e7deedf7a 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -877,6 +877,22 @@ class MQTT: await self._wait_for_mid(mid) + async def _async_resubscribe_and_publish_birth_message( + self, birth_message: PublishMessage + ) -> None: + """Resubscribe to all topics and publish birth message.""" + await self._async_perform_subscriptions() + await self._ha_started.wait() # Wait for Home Assistant to start + await self._discovery_cooldown() # Wait for MQTT discovery to cool down + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) + await self.async_publish( + topic=birth_message.topic, + payload=birth_message.payload, + qos=birth_message.qos, + retain=birth_message.retain, + ) + @callback def _async_mqtt_on_connect( self, @@ -918,36 +934,33 @@ class MQTT: result_code, ) - self.hass.async_create_task(self._async_resubscribe()) - + self._async_queue_resubscribe() + birth: dict[str, Any] if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH): - - async def publish_birth_message(birth_message: PublishMessage) -> None: - await self._ha_started.wait() # Wait for Home Assistant to start - await self._discovery_cooldown() # Wait for MQTT discovery to cool down - # Update subscribe cooldown period to a shorter time - self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) - await self.async_publish( - topic=birth_message.topic, - payload=birth_message.payload, - qos=birth_message.qos, - retain=birth_message.retain, - ) - birth_message = PublishMessage(**birth) self.config_entry.async_create_background_task( self.hass, - publish_birth_message(birth_message), - name="mqtt birth message", + self._async_resubscribe_and_publish_birth_message(birth_message), + name="mqtt re-subscribe and birth", ) else: # Update subscribe cooldown period to a shorter time + self.config_entry.async_create_background_task( + self.hass, + self._async_perform_subscriptions(), + name="mqtt re-subscribe", + ) self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) self._async_connection_result(True) - async def _async_resubscribe(self) -> None: - """Resubscribe on reconnect.""" + @callback + def _async_queue_resubscribe(self) -> None: + """Queue subscriptions on reconnect. + + self._async_perform_subscriptions must be called + after this method to actually subscribe. + """ self._max_qos.clear() self._retained_topics.clear() # Group subscriptions to only re-subscribe once for each topic. @@ -962,7 +975,6 @@ class MQTT: ], queue_only=True, ) - await self._async_perform_subscriptions() @lru_cache(None) # pylint: disable=method-cache-max-size-none def _matching_subscriptions(self, topic: str) -> list[Subscription]: @@ -1049,7 +1061,9 @@ class MQTT: # The callback signature for on_unsubscribe is different from on_subscribe # see https://github.com/eclipse/paho.mqtt.python/issues/687 # properties and reasoncodes are not used in Home Assistant - self.hass.async_create_task(self._mqtt_handle_mid(mid)) + self.config_entry.async_create_task( + self.hass, self._mqtt_handle_mid(mid), name=f"mqtt handle mid {mid}" + ) async def _mqtt_handle_mid(self, mid: int) -> None: # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid From 1641df18ce12465359b28c077e9dedcb3a32bdf7 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 30 Apr 2024 22:44:56 +0200 Subject: [PATCH 186/272] Store runtime data in entry in Ecovacs (#116445) --- homeassistant/components/ecovacs/__init__.py | 18 +++++------ .../components/ecovacs/binary_sensor.py | 11 +++---- homeassistant/components/ecovacs/button.py | 9 +++--- .../components/ecovacs/controller.py | 11 +++++-- .../components/ecovacs/diagnostics.py | 9 +++--- homeassistant/components/ecovacs/event.py | 8 ++--- homeassistant/components/ecovacs/image.py | 8 ++--- .../components/ecovacs/lawn_mower.py | 8 ++--- homeassistant/components/ecovacs/number.py | 8 ++--- homeassistant/components/ecovacs/select.py | 8 ++--- homeassistant/components/ecovacs/sensor.py | 9 +++--- homeassistant/components/ecovacs/switch.py | 8 ++--- homeassistant/components/ecovacs/vacuum.py | 7 ++--- tests/components/ecovacs/conftest.py | 6 ++-- tests/components/ecovacs/test_init.py | 31 +++++++++++++------ 15 files changed, 79 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index ca4579a31b2..e4924b57641 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -37,6 +37,7 @@ PLATFORMS = [ Platform.SWITCH, Platform.VACUUM, ] +EcovacsConfigEntry = ConfigEntry[EcovacsController] async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -50,21 +51,20 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool: """Set up this integration using UI.""" controller = EcovacsController(hass, entry.data) await controller.initialize() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller + async def on_unload() -> None: + await controller.teardown() + + entry.async_on_unload(on_unload) + entry.runtime_data = controller await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: EcovacsConfigEntry) -> bool: """Unload config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - await hass.data[DOMAIN][entry.entry_id].teardown() - hass.data[DOMAIN].pop(entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ecovacs/binary_sensor.py b/homeassistant/components/ecovacs/binary_sensor.py index cc401cc3ca0..f6e3e34aaa4 100644 --- a/homeassistant/components/ecovacs/binary_sensor.py +++ b/homeassistant/components/ecovacs/binary_sensor.py @@ -11,13 +11,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -52,13 +50,14 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsBinarySensorEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - get_supported_entitites(controller, EcovacsBinarySensor, ENTITY_DESCRIPTIONS) + get_supported_entitites( + config_entry.runtime_data, EcovacsBinarySensor, ENTITY_DESCRIPTIONS + ) ) diff --git a/homeassistant/components/ecovacs/button.py b/homeassistant/components/ecovacs/button.py index 27f729a1ae0..14fd54df5a0 100644 --- a/homeassistant/components/ecovacs/button.py +++ b/homeassistant/components/ecovacs/button.py @@ -11,13 +11,12 @@ from deebot_client.capabilities import ( from deebot_client.events import LifeSpan from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, SUPPORTED_LIFESPANS -from .controller import EcovacsController +from . import EcovacsConfigEntry +from .const import SUPPORTED_LIFESPANS from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -66,11 +65,11 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsButtonEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/ecovacs/controller.py b/homeassistant/components/ecovacs/controller.py index 6b6fe3128dd..690f4e56cc9 100644 --- a/homeassistant/components/ecovacs/controller.py +++ b/homeassistant/components/ecovacs/controller.py @@ -42,7 +42,7 @@ class EcovacsController: """Initialize controller.""" self._hass = hass self._devices: list[Device] = [] - self.legacy_devices: list[VacBot] = [] + self._legacy_devices: list[VacBot] = [] rest_url = config.get(CONF_OVERRIDE_REST_URL) self._device_id = get_client_device_id(hass, rest_url is not None) country = config[CONF_COUNTRY] @@ -101,7 +101,7 @@ class EcovacsController: self._continent, monitor=True, ) - self.legacy_devices.append(bot) + self._legacy_devices.append(bot) except InvalidAuthenticationError as ex: raise ConfigEntryError("Invalid credentials") from ex except DeebotError as ex: @@ -113,7 +113,7 @@ class EcovacsController: """Disconnect controller.""" for device in self._devices: await device.teardown() - for legacy_device in self.legacy_devices: + for legacy_device in self._legacy_devices: await self._hass.async_add_executor_job(legacy_device.disconnect) await self._mqtt.disconnect() await self._authenticator.teardown() @@ -124,3 +124,8 @@ class EcovacsController: for device in self._devices: if isinstance(device.capabilities, capability): yield device + + @property + def legacy_devices(self) -> list[VacBot]: + """Return legacy devices.""" + return self._legacy_devices diff --git a/homeassistant/components/ecovacs/diagnostics.py b/homeassistant/components/ecovacs/diagnostics.py index 9340841223e..50b59b90860 100644 --- a/homeassistant/components/ecovacs/diagnostics.py +++ b/homeassistant/components/ecovacs/diagnostics.py @@ -7,12 +7,11 @@ from typing import Any from deebot_client.capabilities import Capabilities from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL, DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry +from .const import CONF_OVERRIDE_MQTT_URL, CONF_OVERRIDE_REST_URL REDACT_CONFIG = { CONF_USERNAME, @@ -25,10 +24,10 @@ REDACT_DEVICE = {"did", CONF_NAME, "homeId"} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: EcovacsConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data diag: dict[str, Any] = { "config": async_redact_data(config_entry.as_dict(), REDACT_CONFIG) } diff --git a/homeassistant/components/ecovacs/event.py b/homeassistant/components/ecovacs/event.py index fb4c25c7559..9e4dde00b54 100644 --- a/homeassistant/components/ecovacs/event.py +++ b/homeassistant/components/ecovacs/event.py @@ -5,24 +5,22 @@ from deebot_client.device import Device from deebot_client.events import CleanJobStatus, ReportStatsEvent from homeassistant.components.event import EventEntity, EventEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import EcovacsEntity from .util import get_name_key async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data async_add_entities( EcovacsLastJobEventEntity(device) for device in controller.devices(Capabilities) ) diff --git a/homeassistant/components/ecovacs/image.py b/homeassistant/components/ecovacs/image.py index 82e20e19732..1e94dc856ee 100644 --- a/homeassistant/components/ecovacs/image.py +++ b/homeassistant/components/ecovacs/image.py @@ -5,23 +5,21 @@ from deebot_client.device import Device from deebot_client.events.map import CachedMapInfoEvent, MapChangedEvent from homeassistant.components.image import ImageEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import EcovacsEntity async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities = [] for device in controller.devices(VacuumCapabilities): capabilities: VacuumCapabilities = device.capabilities diff --git a/homeassistant/components/ecovacs/lawn_mower.py b/homeassistant/components/ecovacs/lawn_mower.py index 1b13d50cc0c..2561fe22217 100644 --- a/homeassistant/components/ecovacs/lawn_mower.py +++ b/homeassistant/components/ecovacs/lawn_mower.py @@ -15,12 +15,10 @@ from homeassistant.components.lawn_mower import ( LawnMowerEntityEntityDescription, LawnMowerEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import EcovacsEntity _LOGGER = logging.getLogger(__name__) @@ -38,11 +36,11 @@ _STATE_TO_MOWER_STATE = { async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs mowers.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data mowers: list[EcovacsMower] = [ EcovacsMower(device) for device in controller.devices(MowerCapabilities) ] diff --git a/homeassistant/components/ecovacs/number.py b/homeassistant/components/ecovacs/number.py index e53f7e6aae0..bd8ce50aadb 100644 --- a/homeassistant/components/ecovacs/number.py +++ b/homeassistant/components/ecovacs/number.py @@ -10,13 +10,11 @@ from deebot_client.capabilities import Capabilities, CapabilitySet, VacuumCapabi from deebot_client.events import CleanCountEvent, VolumeEvent from homeassistant.components.number import NumberEntity, NumberEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -70,11 +68,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsNumberEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsNumberEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/ecovacs/select.py b/homeassistant/components/ecovacs/select.py index 01d4c5aae6b..4caa6327bb3 100644 --- a/homeassistant/components/ecovacs/select.py +++ b/homeassistant/components/ecovacs/select.py @@ -9,13 +9,11 @@ from deebot_client.device import Device from deebot_client.events import WaterInfoEvent, WorkModeEvent from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -62,11 +60,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities = get_supported_entitites( controller, EcovacsSelectEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/ecovacs/sensor.py b/homeassistant/components/ecovacs/sensor.py index 92d1b10a614..e9229781827 100644 --- a/homeassistant/components/ecovacs/sensor.py +++ b/homeassistant/components/ecovacs/sensor.py @@ -24,7 +24,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( AREA_SQUARE_METERS, ATTR_BATTERY_LEVEL, @@ -37,8 +36,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN, SUPPORTED_LIFESPANS -from .controller import EcovacsController +from . import EcovacsConfigEntry +from .const import SUPPORTED_LIFESPANS from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -171,11 +170,11 @@ LIFESPAN_ENTITY_DESCRIPTIONS = tuple( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsSensor, ENTITY_DESCRIPTIONS diff --git a/homeassistant/components/ecovacs/switch.py b/homeassistant/components/ecovacs/switch.py index 0d2f8f2024f..25ecb53e278 100644 --- a/homeassistant/components/ecovacs/switch.py +++ b/homeassistant/components/ecovacs/switch.py @@ -11,13 +11,11 @@ from deebot_client.capabilities import ( from deebot_client.events import EnableEvent from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .controller import EcovacsController +from . import EcovacsConfigEntry from .entity import ( CapabilityDevice, EcovacsCapabilityEntityDescription, @@ -121,11 +119,11 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSwitchEntityDescription, ...] = ( async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Add entities for passed config_entry in HA.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data entities: list[EcovacsEntity] = get_supported_entitites( controller, EcovacsSwitchEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index 0e990645d7c..5c898694cbb 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -23,15 +23,14 @@ from homeassistant.components.vacuum import ( StateVacuumEntityDescription, VacuumEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify +from . import EcovacsConfigEntry from .const import DOMAIN -from .controller import EcovacsController from .entity import EcovacsEntity from .util import get_name_key @@ -43,11 +42,11 @@ ATTR_COMPONENT_PREFIX = "component_" async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: EcovacsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Ecovacs vacuums.""" - controller: EcovacsController = hass.data[DOMAIN][config_entry.entry_id] + controller = config_entry.runtime_data vacuums: list[EcovacsVacuum | EcovacsLegacyVacuum] = [ EcovacsVacuum(device) for device in controller.devices(VacuumCapabilities) ] diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 1a313957c3e..d4333f65dc4 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -156,8 +156,6 @@ async def init_integration( @pytest.fixture -def controller( - hass: HomeAssistant, init_integration: MockConfigEntry -) -> EcovacsController: +def controller(init_integration: MockConfigEntry) -> EcovacsController: """Get the controller for the config entry.""" - return hass.data[DOMAIN][init_integration.entry_id] + return init_integration.runtime_data diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index c27da2196b1..752276015d3 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -20,21 +20,34 @@ from .const import IMPORT_DATA from tests.common import MockConfigEntry -@pytest.mark.usefixtures("init_integration") +@pytest.mark.usefixtures( + "mock_authenticator", "mock_mqtt_client", "mock_device_execute" +) async def test_load_unload_config_entry( hass: HomeAssistant, - init_integration: MockConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test loading and unloading the integration.""" - mock_config_entry = init_integration - assert mock_config_entry.state is ConfigEntryState.LOADED - assert DOMAIN in hass.data + with patch( + "homeassistant.components.ecovacs.EcovacsController", + autospec=True, + ): + mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_unload(mock_config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - assert DOMAIN not in hass.data + assert mock_config_entry.state is ConfigEntryState.LOADED + assert DOMAIN not in hass.data + controller = mock_config_entry.runtime_data + assert isinstance(controller, EcovacsController) + controller.initialize.assert_called_once() + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + controller.teardown.assert_called_once() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED @pytest.fixture From 963d8d6a76e167a926c0498c380f28dbfb9f6b11 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:47:17 -0400 Subject: [PATCH 187/272] Change SkyConnect integration type back to `hardware` and fix multi-PAN migration bug (#116474) Co-authored-by: Joost Lekkerkerker --- .../homeassistant_sky_connect/config_flow.py | 15 ++++++++ .../homeassistant_sky_connect/manifest.json | 2 +- homeassistant/generated/integrations.json | 5 --- .../test_config_flow.py | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 6ffb2783165..9d0aa902cc4 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -597,6 +597,21 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( """Return the name of the hardware.""" return self._hw_variant.full_name + async def async_step_flashing_complete( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Finish flashing and update the config entry.""" + self.hass.config_entries.async_update_entry( + entry=self.config_entry, + data={ + **self.config_entry.data, + "firmware": ApplicationType.EZSP.value, + }, + options=self.config_entry.options, + ) + + return await super().async_step_flashing_complete(user_input) + class HomeAssistantSkyConnectOptionsFlowHandler( BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry diff --git a/homeassistant/components/homeassistant_sky_connect/manifest.json b/homeassistant/components/homeassistant_sky_connect/manifest.json index c90ea2c075f..f56fd24de61 100644 --- a/homeassistant/components/homeassistant_sky_connect/manifest.json +++ b/homeassistant/components/homeassistant_sky_connect/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "dependencies": ["hardware", "usb", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect", - "integration_type": "device", + "integration_type": "hardware", "usb": [ { "vid": "10C4", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cf5f352f22c..e6a103989d1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2565,11 +2565,6 @@ "integration_type": "virtual", "supported_by": "netatmo" }, - "homeassistant_sky_connect": { - "name": "Home Assistant SkyConnect", - "integration_type": "device", - "config_flow": true - }, "homematic": { "name": "Homematic", "integrations": { diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index c34e3ebe186..611dda4a917 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -11,6 +11,8 @@ from universal_silabs_flasher.const import ApplicationType from homeassistant.components import usb from homeassistant.components.hassio.addon_manager import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + CONF_DISABLE_MULTI_PAN, + get_flasher_addon_manager, get_multiprotocol_addon_manager, ) from homeassistant.components.homeassistant_sky_connect.config_flow import ( @@ -869,11 +871,25 @@ async def test_options_flow_multipan_uninstall( version="1.0.0", ) + mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass)) + mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + with ( patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=mock_multipan_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager", + return_value=mock_flasher_manager, + ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", return_value=True, @@ -883,3 +899,25 @@ async def test_options_flow_multipan_uninstall( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "addon_menu" assert "uninstall_addon" in result["menu_options"] + + # Pick the uninstall option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": "uninstall_addon"}, + ) + + # Check the box + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True} + ) + + # Finish the flow + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + + # We've reverted the firmware back to Zigbee + assert config_entry.data["firmware"] == "ezsp" From d524baafd2f74616797c49bbd956f095a7bc09e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:47:27 -0500 Subject: [PATCH 188/272] Fix non-thread-safe operation in roon volume callback (#116475) --- homeassistant/components/roon/event.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index ea5014c8755..073b58160f6 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -72,7 +72,6 @@ class RoonEventEntity(EventEntity): via_device=(DOMAIN, self._server.roon_id), ) - @callback def _roonapi_volume_callback( self, control_key: str, event: str, value: int ) -> None: @@ -88,7 +87,7 @@ class RoonEventEntity(EventEntity): event = "volume_down" self._trigger_event(event) - self.async_write_ha_state() + self.schedule_update_ha_state() async def async_added_to_hass(self) -> None: """Register volume hooks with the roon api.""" From 0d0865e7838a34d37e4c7b7ce5ce21031b050080 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 30 Apr 2024 22:49:28 +0200 Subject: [PATCH 189/272] Bump bimmer_connected to 0.15.2 (#116424) Co-authored-by: Richard --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/conftest.py | 3 +- .../snapshots/test_diagnostics.ambr | 1618 +++++++++-------- 5 files changed, 871 insertions(+), 756 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 854a2f87410..c6b180ca728 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.6"] + "requirements": ["bimmer-connected[china]==0.15.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index ad4c422db8c..0126545fef0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -544,7 +544,7 @@ beautifulsoup4==4.12.3 bellows==0.38.2 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.6 +bimmer-connected[china]==0.15.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 075f7ac573a..1e6dd4b46ac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -469,7 +469,7 @@ beautifulsoup4==4.12.3 bellows==0.38.2 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.6 +bimmer-connected[china]==0.15.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index c3a89e28bd6..f43a7c089c7 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator -from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_STATES +from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_PROFILES, ALL_STATES from bimmer_connected.tests.common import MyBMWMockRouter from bimmer_connected.vehicle import remote_services import pytest @@ -23,6 +23,7 @@ def bmw_fixture( "WBA00000000DEMO03", "WBY00000000REXI01", ], + profiles=ALL_PROFILES, states=ALL_STATES, charging_settings=ALL_CHARGING_SETTINGS, ) diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index b3af5bc59b6..351c0f062fd 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -140,19 +140,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'INACTIVE', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -206,9 +195,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'BLUETOOTH', 'bodyType': 'I20', 'brand': 'BMW_I', 'color': 4285537312, @@ -223,7 +210,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'iX xDrive50', 'softwareVersionCurrent': dict({ 'iStep': 300, @@ -413,14 +399,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -786,19 +764,8 @@ 'is_pre_entry_climatization_enabled', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': 'CHARGING', 'charging_target': 80, 'is_charger_connected': True, @@ -829,6 +796,7 @@ 'software_version': '07/2021.00', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': True, 'is_remote_charge_stop_enabled': True, @@ -1026,19 +994,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'HEATING', 'activity_end_time': '2022-07-10T11:29:50+00:00', - 'activity_end_time_no_tz': '2022-07-10T11:29:50', 'is_climate_on': True, }), 'condition_based_services': dict({ @@ -1092,9 +1049,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', 'bodyType': 'G26', 'brand': 'BMW', 'color': 4284245350, @@ -1109,7 +1064,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'i4 eDrive40', 'softwareVersionCurrent': dict({ 'iStep': 470, @@ -1287,14 +1241,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -1651,19 +1597,8 @@ 'is_pre_entry_climatization_enabled', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': 'NOT_CHARGING', 'charging_target': 80, 'is_charger_connected': False, @@ -1694,6 +1629,7 @@ 'software_version': '11/2021.70', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -1770,23 +1706,7 @@ 'windows', ]), 'brand': 'bmw', - 'charging_profile': dict({ - 'ac_available_limits': None, - 'ac_current_limit': None, - 'charging_mode': 'IMMEDIATE_CHARGING', - 'charging_preferences': 'NO_PRESELECTION', - 'charging_preferences_service_pack': None, - 'departure_times': list([ - ]), - 'is_pre_entry_climatization_enabled': False, - 'preferred_charging_window': dict({ - '_window_dict': dict({ - }), - 'end_time': '00:00:00', - 'start_time': '00:00:00', - }), - 'timer_type': 'UNKNOWN', - }), + 'charging_profile': None, 'check_control_messages': dict({ 'has_check_control_messages': False, 'messages': list([ @@ -1803,19 +1723,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'INACTIVE', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -1878,9 +1787,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', 'bodyType': 'G20', 'brand': 'BMW', 'color': 4280233344, @@ -1895,7 +1802,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'M340i xDrive', 'softwareVersionCurrent': dict({ 'iStep': 470, @@ -1974,17 +1880,8 @@ 'vehicleFinder': True, 'vehicleStateSource': 'LAST_STATE_CALL', }), - 'charging_settings': dict({ - }), + 'charging_settings': None, 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingMode': 'IMMEDIATE_CHARGING', @@ -2288,19 +2185,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': None, 'charging_target': None, 'is_charger_connected': False, @@ -2331,6 +2217,7 @@ 'software_version': '07/2021.70', }), 'is_charging_plan_supported': False, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -2540,19 +2427,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'UNKNOWN', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -2588,9 +2464,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'CONNECTED', 'attributes': dict({ - 'a4aType': 'USB_ONLY', 'bodyType': 'I01', 'brand': 'BMW_I', 'color': 4284110934, @@ -2602,9 +2476,9 @@ 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), + 'headUnitRaw': 'MGU_02_L', 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -2622,6 +2496,7 @@ }), 'seriesCluster': 'I001', }), + 'telematicsUnit': 'WAVE01', 'year': 2015, }), 'capabilities': dict({ @@ -2731,10 +2606,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -2989,19 +2860,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, @@ -3032,6 +2892,7 @@ 'software_version': '11/2021.10', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -3066,204 +2927,297 @@ ]), 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ @@ -4875,19 +4829,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'UNKNOWN', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -4923,9 +4866,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'CONNECTED', 'attributes': dict({ - 'a4aType': 'USB_ONLY', 'bodyType': 'I01', 'brand': 'BMW_I', 'color': 4284110934, @@ -4937,9 +4878,9 @@ 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), + 'headUnitRaw': 'MGU_02_L', 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -4957,6 +4898,7 @@ }), 'seriesCluster': 'I001', }), + 'telematicsUnit': 'WAVE01', 'year': 2015, }), 'capabilities': dict({ @@ -5066,10 +5008,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -5324,19 +5262,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, @@ -5367,6 +5294,7 @@ 'software_version': '11/2021.10', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -5400,204 +5328,297 @@ }), 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ @@ -7062,204 +7083,297 @@ 'data': None, 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ From d39707ee4f146e68110c8846356b056e66f65914 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 May 2024 00:46:25 +0200 Subject: [PATCH 190/272] Update frontend to 20240430.0 (#116481) --- 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 e271903a27d..aa1d8ee3d3c 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==20240429.0"] + "requirements": ["home-assistant-frontend==20240430.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9705b40bd0..6edd57834a5 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 0126545fef0..62da84559a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e6dd4b46ac..d372de982ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From 2401580b6f41fe72f1360493ee46e8a842bd04ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 17:54:33 -0500 Subject: [PATCH 191/272] Make a copy of capability_attributes instead of making a new dict (#116477) --- homeassistant/components/input_datetime/__init__.py | 2 +- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/components/vacuum/__init__.py | 3 +-- homeassistant/components/water_heater/__init__.py | 3 +-- homeassistant/helpers/entity.py | 6 +++--- 5 files changed, 7 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index c64ef506670..9546b51ee4f 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -333,7 +333,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): return self._current_datetime.strftime(FMT_TIME) @property - def capability_attributes(self) -> dict: + def capability_attributes(self) -> dict[str, Any]: """Return the capability attributes.""" return { CONF_HAS_DATE: self.has_date, diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index a955e861c20..ffe324fc8c4 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -360,7 +360,7 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): @property @override - def capability_attributes(self) -> Mapping[str, Any] | None: + def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes.""" if state_class := self.state_class: return {ATTR_STATE_CLASS: state_class} diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index fab26ebc8c5..b50068de149 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta from enum import IntFlag from functools import cached_property, partial @@ -231,7 +230,7 @@ class StateVacuumEntity( ) @property - def capability_attributes(self) -> Mapping[str, Any] | None: + def capability_attributes(self) -> dict[str, Any] | None: """Return capability attributes.""" if VacuumEntityFeature.FAN_SPEED in self.supported_features_compat: return {ATTR_FAN_SPEED_LIST: self.fan_speed_list} diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index 6ea0a2bac6a..d6871947b77 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Mapping from datetime import timedelta from enum import IntFlag import functools as ft @@ -225,7 +224,7 @@ class WaterHeaterEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): return PRECISION_WHOLE @property - def capability_attributes(self) -> Mapping[str, Any]: + def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" data: dict[str, Any] = { ATTR_MIN_TEMP: show_temp( diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index cc8374350cc..3d6623a37f8 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -533,7 +533,7 @@ class Entity( _attr_assumed_state: bool = False _attr_attribution: str | None = None _attr_available: bool = True - _attr_capability_attributes: Mapping[str, Any] | None = None + _attr_capability_attributes: dict[str, Any] | None = None _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None @@ -744,7 +744,7 @@ class Entity( return self._attr_state @cached_property - def capability_attributes(self) -> Mapping[str, Any] | None: + def capability_attributes(self) -> dict[str, Any] | None: """Return the capability attributes. Attributes that explain the capabilities of an entity. @@ -1065,7 +1065,7 @@ class Entity( entry = self.registry_entry capability_attr = self.capability_attributes - attr = dict(capability_attr) if capability_attr else {} + attr = capability_attr.copy() if capability_attr else {} shadowed_attr = {} available = self.available # only call self.available once per update cycle From 3c7cbf5794713fa39c5924e6e7ad5f0ac6e681d4 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 May 2024 01:15:46 +0200 Subject: [PATCH 192/272] Add test MQTT subscription is completed when birth message is sent (#116476) --- tests/components/mqtt/test_init.py | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index fc9e596346f..4fa4291c0aa 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2534,6 +2534,75 @@ async def test_delayed_birth_message( ) +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "homeassistant/status", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + } + ], +) +async def test_subscription_done_when_birth_message_is_sent( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_config_entry_data, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test sending birth message until initial subscription has been completed.""" + mqtt_mock = await mqtt_mock_entry() + + hass.set_state(CoreState.starting) + birth = asyncio.Event() + + await hass.async_block_till_done() + + entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mqtt_component_mock = MagicMock( + return_value=hass.data["mqtt"].client, + wraps=hass.data["mqtt"].client, + ) + mqtt_component_mock._mqttc = mqtt_client_mock + + hass.data["mqtt"].client = mqtt_component_mock + mqtt_mock = hass.data["mqtt"].client + mqtt_mock.reset_mock() + + @callback + def wait_birth(msg: ReceiveMessage) -> None: + """Handle birth message.""" + birth.set() + + mqtt_client_mock.reset_mock() + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0): + await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) + mqtt_client_mock.on_connect(None, None, 0, 0) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # We wait until we receive a birth message + await asyncio.wait_for(birth.wait(), 1) + # Assert we already have subscribed at the client + # for new config payloads at the time we the birth message is received + assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls( + mqtt_client_mock + ) + assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls( + mqtt_client_mock + ) + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ From 6cf1c5c1f26610dad063c4a469c95603bcde659c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 18:47:12 -0500 Subject: [PATCH 193/272] Hold a lock to prevent concurrent setup of config entries (#116482) --- homeassistant/config_entries.py | 30 +++-- homeassistant/setup.py | 2 +- .../androidtv_remote/test_config_flow.py | 3 + .../components/config/test_config_entries.py | 5 + tests/components/mqtt/test_init.py | 1 + tests/components/opower/test_config_flow.py | 2 + tests/test_config_entries.py | 104 +++++++++++++++++- 7 files changed, 129 insertions(+), 18 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 123424108fc..ba642cc0216 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -295,7 +295,7 @@ class ConfigEntry(Generic[_DataT]): update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None - reload_lock: asyncio.Lock + setup_lock: asyncio.Lock _reauth_lock: asyncio.Lock _reconfigure_lock: asyncio.Lock _tasks: set[asyncio.Future[Any]] @@ -403,7 +403,7 @@ class ConfigEntry(Generic[_DataT]): _setter(self, "_on_unload", None) # Reload lock to prevent conflicting reloads - _setter(self, "reload_lock", asyncio.Lock()) + _setter(self, "setup_lock", asyncio.Lock()) # Reauth lock to prevent concurrent reauth flows _setter(self, "_reauth_lock", asyncio.Lock()) # Reconfigure lock to prevent concurrent reconfigure flows @@ -702,19 +702,17 @@ class ConfigEntry(Generic[_DataT]): # has started so we do not block shutdown if not hass.is_stopping: hass.async_create_background_task( - self._async_setup_retry(hass), + self.async_setup_locked(hass), f"config entry retry {self.domain} {self.title}", eager_start=True, ) - async def _async_setup_retry(self, hass: HomeAssistant) -> None: - """Retry setup. - - We hold the reload lock during setup retry to ensure - that nothing can reload the entry while we are retrying. - """ - async with self.reload_lock: - await self.async_setup(hass) + async def async_setup_locked( + self, hass: HomeAssistant, integration: loader.Integration | None = None + ) -> None: + """Set up while holding the setup lock.""" + async with self.setup_lock: + await self.async_setup(hass, integration=integration) @callback def async_shutdown(self) -> None: @@ -1794,7 +1792,15 @@ class ConfigEntries: # attempts. entry.async_cancel_retry_setup() - async with entry.reload_lock: + if entry.domain not in self.hass.config.components: + # If the component is not loaded, just load it as + # the config entry will be loaded as well. We need + # to do this before holding the lock to avoid a + # deadlock. + await async_setup_component(self.hass, entry.domain, self._hass_config) + return entry.state is ConfigEntryState.LOADED + + async with entry.setup_lock: unload_result = await self.async_unload(entry_id) if not unload_result or entry.disabled_by: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 5d562816a6f..8d7161d04e1 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -449,7 +449,7 @@ async def _async_setup_component( await asyncio.gather( *( create_eager_task( - entry.async_setup(hass, integration=integration), + entry.async_setup_locked(hass, integration=integration), name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}", ) for entry in entries diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 8778630be8d..062b9a4a55c 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -324,6 +324,7 @@ async def test_user_flow_already_configured_host_changed_reloads_entry( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -640,6 +641,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -769,6 +771,7 @@ async def test_reauth_flow_success( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index dd46921c339..87c712b3716 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -251,6 +251,7 @@ async def test_reload_entry(hass: HomeAssistant, client) -> None: domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED ) entry.add_to_hass(hass) + hass.config.components.add("kitchen_sink") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -298,6 +299,7 @@ async def test_reload_entry_in_failed_state( """Test reloading an entry via the API that has already failed to unload.""" entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.FAILED_UNLOAD) entry.add_to_hass(hass) + hass.config.components.add("demo") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -326,6 +328,7 @@ async def test_reload_entry_in_setup_retry( entry = MockConfigEntry(domain="comp", state=core_ce.ConfigEntryState.SETUP_RETRY) entry.supports_unload = True entry.add_to_hass(hass) + hass.config.components.add("comp") with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): resp = await client.post( @@ -1109,6 +1112,7 @@ async def test_update_prefrences( domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED ) entry.add_to_hass(hass) + hass.config.components.add("kitchen_sink") assert entry.pref_disable_new_entities is False assert entry.pref_disable_polling is False @@ -1209,6 +1213,7 @@ async def test_disable_entry( ) entry.add_to_hass(hass) assert entry.disabled_by is None + hass.config.components.add("kitchen_sink") # Disable await ws_client.send_json( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 4fa4291c0aa..94a8c4831b4 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1873,6 +1873,7 @@ async def test_reload_entry_with_restored_subscriptions( # Setup the MQTT entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) + hass.config.components.add(mqtt.DOMAIN) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value={}): await entry.async_setup(hass) diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 512a602a043..18a7caf23df 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -279,6 +279,7 @@ async def test_form_valid_reauth( ) -> None: """Test that we can handle a valid reauth.""" mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() @@ -328,6 +329,7 @@ async def test_form_valid_reauth_with_mfa( }, ) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 68f770631ed..8d7efad8918 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -825,7 +825,7 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: "error_reason_translation_placeholders", "_async_cancel_retry_setup", "_on_unload", - "reload_lock", + "setup_lock", "_reauth_lock", "_tasks", "_background_tasks", @@ -1632,7 +1632,6 @@ async def test_entry_reload_succeed( mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) - assert len(async_unload_entry.mock_calls) == 1 assert len(async_setup.mock_calls) == 1 assert len(async_setup_entry.mock_calls) == 1 assert entry.state is config_entries.ConfigEntryState.LOADED @@ -1707,6 +1706,8 @@ async def test_entry_reload_error( ), ) + hass.config.components.add("comp") + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): assert await manager.async_reload(entry.entry_id) @@ -1738,8 +1739,11 @@ async def test_entry_disable_succeed( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") # Disable + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 assert await manager.async_set_disabled_by( entry.entry_id, config_entries.ConfigEntryDisabler.USER ) @@ -1751,7 +1755,7 @@ async def test_entry_disable_succeed( # Enable assert await manager.async_set_disabled_by(entry.entry_id, None) assert len(async_unload_entry.mock_calls) == 1 - assert len(async_setup.mock_calls) == 1 + assert len(async_setup.mock_calls) == 0 assert len(async_setup_entry.mock_calls) == 1 assert entry.state is config_entries.ConfigEntryState.LOADED @@ -1775,6 +1779,7 @@ async def test_entry_disable_without_reload_support( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") # Disable assert not await manager.async_set_disabled_by( @@ -1951,7 +1956,7 @@ async def test_reload_entry_entity_registry_works( ) await hass.async_block_till_done() - assert len(mock_unload_entry.mock_calls) == 2 + assert len(mock_unload_entry.mock_calls) == 1 async def test_unique_id_persisted( @@ -3392,6 +3397,7 @@ async def test_entry_reload_calls_on_unload_listeners( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") mock_unload_callback = Mock() @@ -3944,8 +3950,9 @@ async def test_deprecated_disabled_by_str_set( caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" - entry = MockConfigEntry() + entry = MockConfigEntry(domain="comp") entry.add_to_manager(manager) + hass.config.components.add("comp") assert await manager.async_set_disabled_by( entry.entry_id, config_entries.ConfigEntryDisabler.USER.value ) @@ -3963,6 +3970,47 @@ async def test_entry_reload_concurrency( async_setup = AsyncMock(return_value=True) loaded = 1 + async def _async_setup_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded += 1 + return loaded == 1 + + async def _async_unload_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded -= 1 + return loaded == 0 + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=_async_setup_entry, + async_unload_entry=_async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + tasks = [ + asyncio.create_task(manager.async_reload(entry.entry_id)) for _ in range(15) + ] + await asyncio.gather(*tasks) + assert entry.state is config_entries.ConfigEntryState.LOADED + assert loaded == 1 + + +async def test_entry_reload_concurrency_not_setup_setup( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test multiple reload calls do not cause a reload race.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + loaded = 0 + async def _async_setup_entry(*args, **kwargs): await asyncio.sleep(0) nonlocal loaded @@ -4074,6 +4122,7 @@ async def test_disallow_entry_reload_with_setup_in_progress( domain="comp", state=config_entries.ConfigEntryState.SETUP_IN_PROGRESS ) entry.add_to_hass(hass) + hass.config.components.add("comp") with pytest.raises( config_entries.OperationNotAllowed, @@ -5016,3 +5065,48 @@ async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): hass.config_entries.async_update_entry(entry, unique_id="new_id") + + +async def test_reload_during_setup(hass: HomeAssistant) -> None: + """Test reload during setup waits.""" + entry = MockConfigEntry(domain="comp", data={"value": "initial"}) + entry.add_to_hass(hass) + + setup_start_future = hass.loop.create_future() + setup_finish_future = hass.loop.create_future() + in_setup = False + setup_calls = 0 + + async def mock_async_setup_entry(hass, entry): + """Mock setting up an entry.""" + nonlocal in_setup + nonlocal setup_calls + setup_calls += 1 + assert not in_setup + in_setup = True + setup_start_future.set_result(None) + await setup_finish_future + in_setup = False + return True + + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_async_setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + + setup_task = hass.async_create_task(async_setup_component(hass, "comp", {})) + + await setup_start_future # ensure we are in the setup + reload_task = hass.async_create_task( + hass.config_entries.async_reload(entry.entry_id) + ) + await asyncio.sleep(0) + setup_finish_future.set_result(None) + await setup_task + await reload_task + assert setup_calls == 2 From 58c7a97149c36f89657b7785b7abc326e81e12ca Mon Sep 17 00:00:00 2001 From: max2697 <143563471+max2697@users.noreply.github.com> Date: Wed, 1 May 2024 00:11:47 -0500 Subject: [PATCH 194/272] Bump opower to 0.4.4 (#116489) --- 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 51ad669733b..91e4fbc960c 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.4.3"] + "requirements": ["opower==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 62da84559a5..1bc4c704814 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.3 +opower==0.4.4 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d372de982ff..aa88515d384 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.4 # homeassistant.components.opower -opower==0.4.3 +opower==0.4.4 # homeassistant.components.oralb oralb-ble==0.17.6 From 7a10959e58842837b60f6fea5ba581000ca181ad Mon Sep 17 00:00:00 2001 From: wittypluck Date: Wed, 1 May 2024 08:46:03 +0200 Subject: [PATCH 195/272] Use websocket client to test device removal in Unifi (#116309) * Use websocket client to test device removal from registry * Rename client to ws_client to avoid confusion with Unifi clients * Use remove_device helper --- tests/components/unifi/test_init.py | 41 ++++++++++++++++++----------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index bd9a29f2c8b..f358c03d98d 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -5,7 +5,6 @@ from unittest.mock import patch from aiounifi.models.message import MessageKey -from homeassistant import loader from homeassistant.components import unifi from homeassistant.components.unifi.const import ( CONF_ALLOW_BANDWIDTH_SENSORS, @@ -23,6 +22,7 @@ from .test_hub import DEFAULT_CONFIG_ENTRY_ID, setup_unifi_integration from tests.common import flush_store from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import WebSocketGenerator async def test_setup_with_no_config(hass: HomeAssistant) -> None: @@ -121,6 +121,7 @@ async def test_remove_config_entry_device( aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, mock_unifi_websocket, + hass_ws_client: WebSocketGenerator, ) -> None: """Verify removing a device manually.""" client_1 = { @@ -173,31 +174,39 @@ async def test_remove_config_entry_device( devices_response=[device_1], ) - integration = await loader.async_get_integration(hass, config_entry.domain) - component = await integration.async_get_component() + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) - # Remove a client - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[client_2]) - await hass.async_block_till_done() - - # Try to remove an active client: not allowed + # Try to remove an active client from UI: not allowed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} ) - assert not await component.async_remove_config_entry_device( - hass, config_entry, device_entry + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} ) - # Try to remove an active device: not allowed + + # Try to remove an active device from UI: not allowed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} ) - assert not await component.async_remove_config_entry_device( - hass, config_entry, device_entry + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert not response["success"] + assert device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} ) - # Try to remove an inactive client: allowed + + # Remove a client from Unifi API + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[client_2]) + await hass.async_block_till_done() + + # Try to remove an inactive client from UI: allowed device_entry = device_registry.async_get_device( connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} ) - assert await component.async_remove_config_entry_device( - hass, config_entry, device_entry + response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) + assert response["success"] + assert not device_registry.async_get_device( + connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} ) From ac608ef86a6de62bcff9d136110312c694719c4e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 May 2024 11:19:26 +0200 Subject: [PATCH 196/272] Remove unused argument from DWD coordinator (#116496) --- homeassistant/components/dwd_weather_warnings/__init__.py | 2 +- homeassistant/components/dwd_weather_warnings/coordinator.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 9cf73a90a73..209c77f60b5 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -11,7 +11,7 @@ from .coordinator import DwdWeatherWarningsCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry.""" - coordinator = DwdWeatherWarningsCoordinator(hass, entry) + coordinator = DwdWeatherWarningsCoordinator(hass) await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py index 465a7c09750..7600a04f2bb 100644 --- a/homeassistant/components/dwd_weather_warnings/coordinator.py +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -26,7 +26,7 @@ class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): config_entry: ConfigEntry api: DwdWeatherWarningsAPI - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + def __init__(self, hass: HomeAssistant) -> None: """Initialize the dwd_weather_warnings coordinator.""" super().__init__( hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL From 8230bfcf8f58ddfbd52b548c1cd5cfeb9e472736 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 11:46:52 +0200 Subject: [PATCH 197/272] Some fixes for the Matter light discovery schema (#116108) * Fix discovery schema for light platform * fix switch platform discovery schema * extend light tests * Update switch.py * clarify comment * use parameter for supported_color_modes --- homeassistant/components/matter/light.py | 41 +-- homeassistant/components/matter/switch.py | 6 +- ...onoff-light-with-levelcontrol-present.json | 244 ++++++++++++++++++ tests/components/matter/test_light.py | 29 ++- 4 files changed, 273 insertions(+), 47 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index fce780896a4..c9556fd2e2e 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -295,7 +295,10 @@ class MatterLight(MatterEntity, LightEntity): # brightness support if self._entity_info.endpoint.has_attribute( None, clusters.LevelControl.Attributes.CurrentLevel - ): + ) and self._entity_info.endpoint.device_types != {device_types.OnOffLight}: + # We need to filter out the OnOffLight device type here because + # that can have an optional LevelControl cluster present + # which we should ignore. supported_color_modes.add(ColorMode.BRIGHTNESS) self._supports_brightness = True # colormode(s) @@ -406,11 +409,11 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterLight, required_attributes=( clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.CurrentHue, clusters.ColorControl.Attributes.CurrentSaturation, ), optional_attributes=( + clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorTemperatureMireds, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentX, @@ -426,11 +429,11 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterLight, required_attributes=( clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.CurrentX, clusters.ColorControl.Attributes.CurrentY, ), optional_attributes=( + clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorTemperatureMireds, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentHue, @@ -451,36 +454,4 @@ DISCOVERY_SCHEMAS = [ ), optional_attributes=(clusters.ColorControl.Attributes.ColorMode,), ), - # Additional schema to match generic dimmable lights with incorrect/missing device type - MatterDiscoverySchema( - platform=Platform.LIGHT, - entity_description=LightEntityDescription( - key="MatterDimmableLightFallback", name=None - ), - entity_class=MatterLight, - required_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - optional_attributes=( - clusters.ColorControl.Attributes.ColorMode, - clusters.ColorControl.Attributes.CurrentHue, - clusters.ColorControl.Attributes.CurrentSaturation, - clusters.ColorControl.Attributes.CurrentX, - clusters.ColorControl.Attributes.CurrentY, - clusters.ColorControl.Attributes.ColorTemperatureMireds, - ), - # important: make sure to rule out all device types that are also based on the - # onoff and levelcontrol clusters ! - not_device_type=( - device_types.Fan, - device_types.GenericSwitch, - device_types.OnOffPlugInUnit, - device_types.HeatingCoolingUnit, - device_types.Pump, - device_types.CastingVideoClient, - device_types.VideoRemoteControl, - device_types.Speaker, - ), - ), ] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 9bc858d40c0..f148102cfcd 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -81,12 +81,8 @@ DISCOVERY_SCHEMAS = [ device_types.ColorTemperatureLight, device_types.DimmableLight, device_types.ExtendedColorLight, - device_types.OnOffLight, - device_types.DoorLock, device_types.ColorDimmerSwitch, - device_types.DimmerSwitch, - device_types.Thermostat, - device_types.RoomAirConditioner, + device_types.OnOffLight, ), ), ] diff --git a/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json b/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json new file mode 100644 index 00000000000..c1264f5b7ea --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json @@ -0,0 +1,244 @@ +{ + "node_id": 8, + "date_commissioned": "2024-03-07T01:39:20.590755", + "last_interview": "2024-04-02T14:16:31.045880", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Leviton", + "0/40/2": 4251, + "0/40/3": "D215S", + "0/40/4": 4097, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 131365, + "0/40/10": "2.1.25", + "0/40/15": "12345678", + "0/40/18": "abcdefgh", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/1": 1, + "0/51/2": 2380987, + "0/51/3": 661, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 49792, + "0/52/2": 262528, + "0/52/3": 272704, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "blah", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -43, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 15, 16, 17, 20, 16384, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 256, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 9c3c2610d92..775790701d1 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -18,21 +18,31 @@ from .common import ( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( - ("fixture", "entity_id"), + ("fixture", "entity_id", "supported_color_modes"), [ - ("extended-color-light", "light.mock_extended_color_light"), - ("color-temperature-light", "light.mock_color_temperature_light"), - ("dimmable-light", "light.mock_dimmable_light"), - ("onoff-light", "light.mock_onoff_light"), + ( + "extended-color-light", + "light.mock_extended_color_light", + ["color_temp", "hs", "xy"], + ), + ( + "color-temperature-light", + "light.mock_color_temperature_light", + ["color_temp"], + ), + ("dimmable-light", "light.mock_dimmable_light", ["brightness"]), + ("onoff-light", "light.mock_onoff_light", ["onoff"]), + ("onoff-light-with-levelcontrol-present", "light.d215s", ["onoff"]), ], ) -async def test_on_off_light( +async def test_light_turn_on_off( hass: HomeAssistant, matter_client: MagicMock, fixture: str, entity_id: str, + supported_color_modes: list[str], ) -> None: - """Test an on/off light.""" + """Test basic light discovery and turn on/off.""" light_node = await setup_integration_with_node_fixture( hass, @@ -48,6 +58,11 @@ async def test_on_off_light( assert state is not None assert state.state == "off" + # check the supported_color_modes + # especially important is the onoff light device type that does have + # a levelcontrol cluster present which we should ignore + assert state.attributes["supported_color_modes"] == supported_color_modes + # Test that the light is on set_node_attribute(light_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) From 835ce919f4c6285605106b7b42264c40746ea91f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 1 May 2024 05:56:02 -0400 Subject: [PATCH 198/272] Fix roborock image crashes (#116487) --- .../components/roborock/coordinator.py | 2 +- homeassistant/components/roborock/image.py | 31 ++++++++-- tests/components/roborock/conftest.py | 4 ++ tests/components/roborock/test_image.py | 62 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 293415360bd..32b7a487ac8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -49,7 +49,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) device_data = DeviceData(device, product_info.model, device_networking.ip) self.api: RoborockLocalClientV1 | RoborockMqttClientV1 = RoborockLocalClientV1( - device_data + device_data, queue_timeout=5 ) self.cloud_api = cloud_api self.device_info = DeviceInfo( diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 775ab98fd59..2aef39ce59b 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -66,17 +66,26 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): ) self._attr_image_last_updated = dt_util.utcnow() self.map_flag = map_flag - self.cached_map = self._create_image(starting_map) + try: + self.cached_map = self._create_image(starting_map) + except HomeAssistantError: + # If we failed to update the image on init, we set cached_map to empty bytes so that we are unavailable and can try again later. + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC + @property + def available(self): + """Determines if the entity is available.""" + return self.cached_map != b"" + @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map def is_map_valid(self) -> bool: - """Update this map if it is the current active map, and the vacuum is cleaning.""" - return ( + """Update this map if it is the current active map, and the vacuum is cleaning or if it has never been set at all.""" + return self.cached_map == b"" or ( self.is_selected and self.image_last_updated is not None and self.coordinator.roborock_device_info.props.status is not None @@ -96,7 +105,16 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" if self.is_map_valid(): - map_data: bytes = await self.cloud_api.get_map_v1() + response = await asyncio.gather( + *(self.cloud_api.get_map_v1(), self.coordinator.get_rooms()), + return_exceptions=True, + ) + if not isinstance(response[0], bytes): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + map_data = response[0] self.cached_map = self._create_image(map_data) return self.cached_map @@ -141,9 +159,10 @@ async def create_coordinator_maps( await asyncio.sleep(MAP_SLEEP) # Get the map data map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.get_rooms()] + *[coord.cloud_api.get_map_v1(), coord.get_rooms()], return_exceptions=True ) - api_data: bytes = map_update[0] + # If we fail to get the map -> We should set it to empty byte, still create it, and set it as unavailable. + api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" entities.append( RoborockMap( f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_info.name}", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 0f3689da161..d3bb0a221b1 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -91,6 +91,10 @@ def bypass_api_fixture() -> None: RoomMapping(18, "2362041"), ], ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"123", + ), ): yield diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 445f90f4a05..bc45c6dec05 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -5,7 +5,12 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +from roborock import RoborockException + +from homeassistant.components.roborock import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -82,3 +87,60 @@ async def test_floorplan_image_failed_parse( async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert not resp.ok + + +async def test_fail_parse_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state == STATE_UNAVAILABLE + + +async def test_fail_updating_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we handle failing getting the image after it has already been setup..""" + client = await hass_client() + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + now = dt_util.utcnow() + timedelta(seconds=91) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get none for parse image. + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + side_effect=RoborockException, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok From 53d5960f492c14b763292e95cb8a6e6da8b8a39b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 May 2024 12:53:45 +0200 Subject: [PATCH 199/272] Update frontend to 20240501.0 (#116503) --- 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 aa1d8ee3d3c..6abe8df1d7c 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==20240430.0"] + "requirements": ["home-assistant-frontend==20240501.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6edd57834a5..fec28850240 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1bc4c704814..8c7caa60eef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa88515d384..c06ed21f066 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From c0d529b072c0f5ef6d62f42e7e9029003a2b77c5 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 11:46:52 +0200 Subject: [PATCH 200/272] Some fixes for the Matter light discovery schema (#116108) * Fix discovery schema for light platform * fix switch platform discovery schema * extend light tests * Update switch.py * clarify comment * use parameter for supported_color_modes --- homeassistant/components/matter/light.py | 41 +-- homeassistant/components/matter/switch.py | 6 +- ...onoff-light-with-levelcontrol-present.json | 244 ++++++++++++++++++ tests/components/matter/test_light.py | 29 ++- 4 files changed, 273 insertions(+), 47 deletions(-) create mode 100644 tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index fce780896a4..c9556fd2e2e 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -295,7 +295,10 @@ class MatterLight(MatterEntity, LightEntity): # brightness support if self._entity_info.endpoint.has_attribute( None, clusters.LevelControl.Attributes.CurrentLevel - ): + ) and self._entity_info.endpoint.device_types != {device_types.OnOffLight}: + # We need to filter out the OnOffLight device type here because + # that can have an optional LevelControl cluster present + # which we should ignore. supported_color_modes.add(ColorMode.BRIGHTNESS) self._supports_brightness = True # colormode(s) @@ -406,11 +409,11 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterLight, required_attributes=( clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.CurrentHue, clusters.ColorControl.Attributes.CurrentSaturation, ), optional_attributes=( + clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorTemperatureMireds, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentX, @@ -426,11 +429,11 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterLight, required_attributes=( clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.CurrentX, clusters.ColorControl.Attributes.CurrentY, ), optional_attributes=( + clusters.LevelControl.Attributes.CurrentLevel, clusters.ColorControl.Attributes.ColorTemperatureMireds, clusters.ColorControl.Attributes.ColorMode, clusters.ColorControl.Attributes.CurrentHue, @@ -451,36 +454,4 @@ DISCOVERY_SCHEMAS = [ ), optional_attributes=(clusters.ColorControl.Attributes.ColorMode,), ), - # Additional schema to match generic dimmable lights with incorrect/missing device type - MatterDiscoverySchema( - platform=Platform.LIGHT, - entity_description=LightEntityDescription( - key="MatterDimmableLightFallback", name=None - ), - entity_class=MatterLight, - required_attributes=( - clusters.OnOff.Attributes.OnOff, - clusters.LevelControl.Attributes.CurrentLevel, - ), - optional_attributes=( - clusters.ColorControl.Attributes.ColorMode, - clusters.ColorControl.Attributes.CurrentHue, - clusters.ColorControl.Attributes.CurrentSaturation, - clusters.ColorControl.Attributes.CurrentX, - clusters.ColorControl.Attributes.CurrentY, - clusters.ColorControl.Attributes.ColorTemperatureMireds, - ), - # important: make sure to rule out all device types that are also based on the - # onoff and levelcontrol clusters ! - not_device_type=( - device_types.Fan, - device_types.GenericSwitch, - device_types.OnOffPlugInUnit, - device_types.HeatingCoolingUnit, - device_types.Pump, - device_types.CastingVideoClient, - device_types.VideoRemoteControl, - device_types.Speaker, - ), - ), ] diff --git a/homeassistant/components/matter/switch.py b/homeassistant/components/matter/switch.py index 9bc858d40c0..f148102cfcd 100644 --- a/homeassistant/components/matter/switch.py +++ b/homeassistant/components/matter/switch.py @@ -81,12 +81,8 @@ DISCOVERY_SCHEMAS = [ device_types.ColorTemperatureLight, device_types.DimmableLight, device_types.ExtendedColorLight, - device_types.OnOffLight, - device_types.DoorLock, device_types.ColorDimmerSwitch, - device_types.DimmerSwitch, - device_types.Thermostat, - device_types.RoomAirConditioner, + device_types.OnOffLight, ), ), ] diff --git a/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json b/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json new file mode 100644 index 00000000000..c1264f5b7ea --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-with-levelcontrol-present.json @@ -0,0 +1,244 @@ +{ + "node_id": 8, + "date_commissioned": "2024-03-07T01:39:20.590755", + "last_interview": "2024-04-02T14:16:31.045880", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 54, 60, 62, 63], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 2 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Leviton", + "0/40/2": 4251, + "0/40/3": "D215S", + "0/40/4": 4097, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "1.0", + "0/40/9": 131365, + "0/40/10": "2.1.25", + "0/40/15": "12345678", + "0/40/18": "abcdefgh", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 18, 19, 65528, 65529, 65531, 65532, + 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/1": 1, + "0/51/2": 2380987, + "0/51/3": 661, + "0/51/4": 0, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/1": 49792, + "0/52/2": 262528, + "0/52/3": 272704, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/54/0": "blah", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 11, + "0/54/4": -43, + "0/54/65532": 0, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/5": 2, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": false, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 254, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 15, 16, 17, 20, 16384, 65528, 65529, 65531, 65532, 65533 + ], + "1/29/0": [ + { + "0": 256, + "1": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 9c3c2610d92..775790701d1 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -18,21 +18,31 @@ from .common import ( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) @pytest.mark.parametrize( - ("fixture", "entity_id"), + ("fixture", "entity_id", "supported_color_modes"), [ - ("extended-color-light", "light.mock_extended_color_light"), - ("color-temperature-light", "light.mock_color_temperature_light"), - ("dimmable-light", "light.mock_dimmable_light"), - ("onoff-light", "light.mock_onoff_light"), + ( + "extended-color-light", + "light.mock_extended_color_light", + ["color_temp", "hs", "xy"], + ), + ( + "color-temperature-light", + "light.mock_color_temperature_light", + ["color_temp"], + ), + ("dimmable-light", "light.mock_dimmable_light", ["brightness"]), + ("onoff-light", "light.mock_onoff_light", ["onoff"]), + ("onoff-light-with-levelcontrol-present", "light.d215s", ["onoff"]), ], ) -async def test_on_off_light( +async def test_light_turn_on_off( hass: HomeAssistant, matter_client: MagicMock, fixture: str, entity_id: str, + supported_color_modes: list[str], ) -> None: - """Test an on/off light.""" + """Test basic light discovery and turn on/off.""" light_node = await setup_integration_with_node_fixture( hass, @@ -48,6 +58,11 @@ async def test_on_off_light( assert state is not None assert state.state == "off" + # check the supported_color_modes + # especially important is the onoff light device type that does have + # a levelcontrol cluster present which we should ignore + assert state.attributes["supported_color_modes"] == supported_color_modes + # Test that the light is on set_node_attribute(light_node, 1, 6, 0, True) await trigger_subscription_callback(hass, matter_client) From 78d19854dda6f60a6b36d20efc889c32c71a91b8 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Tue, 30 Apr 2024 22:49:28 +0200 Subject: [PATCH 201/272] Bump bimmer_connected to 0.15.2 (#116424) Co-authored-by: Richard --- .../bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../bmw_connected_drive/conftest.py | 3 +- .../snapshots/test_diagnostics.ambr | 1618 +++++++++-------- 5 files changed, 871 insertions(+), 756 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 854a2f87410..c6b180ca728 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", "iot_class": "cloud_polling", "loggers": ["bimmer_connected"], - "requirements": ["bimmer-connected[china]==0.14.6"] + "requirements": ["bimmer-connected[china]==0.15.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index dca62841c08..04097f0a9bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -544,7 +544,7 @@ beautifulsoup4==4.12.3 bellows==0.38.2 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.6 +bimmer-connected[china]==0.15.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17411a81818..bbc60f01547 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -469,7 +469,7 @@ beautifulsoup4==4.12.3 bellows==0.38.2 # homeassistant.components.bmw_connected_drive -bimmer-connected[china]==0.14.6 +bimmer-connected[china]==0.15.2 # homeassistant.components.eq3btsmart # homeassistant.components.esphome diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index c3a89e28bd6..f43a7c089c7 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator -from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_STATES +from bimmer_connected.tests import ALL_CHARGING_SETTINGS, ALL_PROFILES, ALL_STATES from bimmer_connected.tests.common import MyBMWMockRouter from bimmer_connected.vehicle import remote_services import pytest @@ -23,6 +23,7 @@ def bmw_fixture( "WBA00000000DEMO03", "WBY00000000REXI01", ], + profiles=ALL_PROFILES, states=ALL_STATES, charging_settings=ALL_CHARGING_SETTINGS, ) diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index b3af5bc59b6..351c0f062fd 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -140,19 +140,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'INACTIVE', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -206,9 +195,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'BLUETOOTH', 'bodyType': 'I20', 'brand': 'BMW_I', 'color': 4285537312, @@ -223,7 +210,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'iX xDrive50', 'softwareVersionCurrent': dict({ 'iStep': 300, @@ -413,14 +399,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -786,19 +764,8 @@ 'is_pre_entry_climatization_enabled', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': 'CHARGING', 'charging_target': 80, 'is_charger_connected': True, @@ -829,6 +796,7 @@ 'software_version': '07/2021.00', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': True, 'is_remote_charge_stop_enabled': True, @@ -1026,19 +994,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'HEATING', 'activity_end_time': '2022-07-10T11:29:50+00:00', - 'activity_end_time_no_tz': '2022-07-10T11:29:50', 'is_climate_on': True, }), 'condition_based_services': dict({ @@ -1092,9 +1049,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', 'bodyType': 'G26', 'brand': 'BMW', 'color': 4284245350, @@ -1109,7 +1064,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'i4 eDrive40', 'softwareVersionCurrent': dict({ 'iStep': 470, @@ -1287,14 +1241,6 @@ 'servicePack': 'WAVE_01', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -1651,19 +1597,8 @@ 'is_pre_entry_climatization_enabled', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': 'NOT_CHARGING', 'charging_target': 80, 'is_charger_connected': False, @@ -1694,6 +1629,7 @@ 'software_version': '11/2021.70', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': True, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -1770,23 +1706,7 @@ 'windows', ]), 'brand': 'bmw', - 'charging_profile': dict({ - 'ac_available_limits': None, - 'ac_current_limit': None, - 'charging_mode': 'IMMEDIATE_CHARGING', - 'charging_preferences': 'NO_PRESELECTION', - 'charging_preferences_service_pack': None, - 'departure_times': list([ - ]), - 'is_pre_entry_climatization_enabled': False, - 'preferred_charging_window': dict({ - '_window_dict': dict({ - }), - 'end_time': '00:00:00', - 'start_time': '00:00:00', - }), - 'timer_type': 'UNKNOWN', - }), + 'charging_profile': None, 'check_control_messages': dict({ 'has_check_control_messages': False, 'messages': list([ @@ -1803,19 +1723,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'INACTIVE', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -1878,9 +1787,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'DEMO', 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', 'bodyType': 'G20', 'brand': 'BMW', 'color': 4280233344, @@ -1895,7 +1802,6 @@ 'headUnitRaw': 'HU_MGU', 'headUnitType': 'MGU', 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', 'model': 'M340i xDrive', 'softwareVersionCurrent': dict({ 'iStep': 470, @@ -1974,17 +1880,8 @@ 'vehicleFinder': True, 'vehicleStateSource': 'LAST_STATE_CALL', }), - 'charging_settings': dict({ - }), + 'charging_settings': None, 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingMode': 'IMMEDIATE_CHARGING', @@ -2288,19 +2185,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': None, - 'charging_start_time_no_tz': None, 'charging_status': None, 'charging_target': None, 'is_charger_connected': False, @@ -2331,6 +2217,7 @@ 'software_version': '07/2021.70', }), 'is_charging_plan_supported': False, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -2540,19 +2427,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'UNKNOWN', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -2588,9 +2464,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'CONNECTED', 'attributes': dict({ - 'a4aType': 'USB_ONLY', 'bodyType': 'I01', 'brand': 'BMW_I', 'color': 4284110934, @@ -2602,9 +2476,9 @@ 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), + 'headUnitRaw': 'MGU_02_L', 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -2622,6 +2496,7 @@ }), 'seriesCluster': 'I001', }), + 'telematicsUnit': 'WAVE01', 'year': 2015, }), 'capabilities': dict({ @@ -2731,10 +2606,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -2989,19 +2860,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, @@ -3032,6 +2892,7 @@ 'software_version': '11/2021.10', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -3066,204 +2927,297 @@ ]), 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ @@ -4875,19 +4829,8 @@ ]), }), 'climate': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'activity': 'UNKNOWN', 'activity_end_time': None, - 'activity_end_time_no_tz': None, 'is_climate_on': False, }), 'condition_based_services': dict({ @@ -4923,9 +4866,7 @@ ]), }), 'data': dict({ - 'appVehicleType': 'CONNECTED', 'attributes': dict({ - 'a4aType': 'USB_ONLY', 'bodyType': 'I01', 'brand': 'BMW_I', 'color': 4284110934, @@ -4937,9 +4878,9 @@ 'iosAppScheme': 'bmwdriversguide:///open', 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', }), + 'headUnitRaw': 'MGU_02_L', 'headUnitType': 'NBT', 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', 'model': 'i3 (+ REX)', 'softwareVersionCurrent': dict({ 'iStep': 510, @@ -4957,6 +4898,7 @@ }), 'seriesCluster': 'I001', }), + 'telematicsUnit': 'WAVE01', 'year': 2015, }), 'capabilities': dict({ @@ -5066,10 +5008,6 @@ 'servicePack': 'TCB1', }), 'fetched_at': '2022-07-10T11:00:00+00:00', - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), 'state': dict({ 'chargingProfile': dict({ 'chargingControlType': 'WEEKLY_PLANNER', @@ -5324,19 +5262,8 @@ 'remaining_fuel_percent', ]), 'fuel_and_battery': dict({ - 'account_timezone': dict({ - '_dst_offset': '0:00:00', - '_dst_saved': '0:00:00', - '_hasdst': False, - '_std_offset': '0:00:00', - '_tznames': list([ - 'UTC', - 'UTC', - ]), - }), 'charging_end_time': None, 'charging_start_time': '2022-07-10T18:01:00+00:00', - 'charging_start_time_no_tz': '2022-07-10T18:01:00', 'charging_status': 'WAITING_FOR_CHARGING', 'charging_target': 100, 'is_charger_connected': True, @@ -5367,6 +5294,7 @@ 'software_version': '11/2021.10', }), 'is_charging_plan_supported': True, + 'is_charging_settings_supported': False, 'is_lsc_enabled': True, 'is_remote_charge_start_enabled': False, 'is_remote_charge_stop_enabled': False, @@ -5400,204 +5328,297 @@ }), 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ @@ -7062,204 +7083,297 @@ 'data': None, 'fingerprint': list([ dict({ - 'content': list([ - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'BLUETOOTH', - 'bodyType': 'I20', - 'brand': 'BMW_I', - 'color': 4285537312, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'iX xDrive50', - 'softwareVersionCurrent': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 300, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S21A', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, + 'content': dict({ + 'capabilities': dict({ + 'climateFunction': 'AIR_CONDITIONING', + 'climateNow': True, + 'climateTimerTrigger': 'DEPARTURE_TIMER', + 'horn': True, + 'isBmwChargingSupported': True, + 'isCarSharingSupported': False, + 'isChargeNowForBusinessSupported': False, + 'isChargingHistorySupported': True, + 'isChargingHospitalityEnabled': False, + 'isChargingLoudnessEnabled': False, + 'isChargingPlanSupported': True, + 'isChargingPowerLimitEnabled': False, + 'isChargingSettingsEnabled': False, + 'isChargingTargetSocEnabled': False, + 'isClimateTimerSupported': True, + 'isCustomerEsimSupported': False, + 'isDCSContractManagementSupported': True, + 'isDataPrivacyEnabled': False, + 'isEasyChargeEnabled': False, + 'isEvGoChargingSupported': False, + 'isMiniChargingSupported': False, + 'isNonLscFeatureEnabled': False, + 'isRemoteEngineStartSupported': False, + 'isRemoteHistoryDeletionSupported': False, + 'isRemoteHistorySupported': True, + 'isRemoteParkingSupported': False, + 'isRemoteServicesActivationRequired': False, + 'isRemoteServicesBookingRequired': False, + 'isScanAndChargeSupported': False, + 'isSustainabilitySupported': False, + 'isWifiHotspotServiceSupported': False, + 'lastStateCallState': 'ACTIVATED', + 'lights': True, + 'lock': True, + 'remoteChargingCommands': dict({ }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ + 'sendPoi': True, + 'specialThemeSupport': list([ + ]), + 'unlock': True, + 'vehicleFinder': False, + 'vehicleStateSource': 'LAST_STATE_CALL', + }), + 'state': dict({ + 'chargingProfile': dict({ + 'chargingControlType': 'WEEKLY_PLANNER', + 'chargingMode': 'DELAYED_CHARGING', + 'chargingPreference': 'CHARGING_WINDOW', + 'chargingSettings': dict({ + 'hospitality': 'NO_ACTION', + 'idcc': 'NO_ACTION', + 'targetSoc': 100, + }), + 'climatisationOn': False, + 'departureTimes': list([ + dict({ + 'action': 'DEACTIVATE', + 'id': 1, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 35, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 2, + 'timeStamp': dict({ + 'hour': 18, + 'minute': 0, + }), + 'timerWeekDays': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 3, + 'timeStamp': dict({ + 'hour': 7, + 'minute': 0, + }), + 'timerWeekDays': list([ + ]), + }), + dict({ + 'action': 'DEACTIVATE', + 'id': 4, + 'timerWeekDays': list([ + ]), + }), ]), - 'mappingStatus': 'CONFIRMED', + 'reductionOfChargeCurrent': dict({ + 'end': dict({ + 'hour': 1, + 'minute': 30, + }), + 'start': dict({ + 'hour': 18, + 'minute': 1, + }), + }), + }), + 'checkControlMessages': list([ + ]), + 'climateTimers': list([ + dict({ + 'departureTime': dict({ + 'hour': 6, + 'minute': 40, + }), + 'isWeeklyTimer': True, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'THURSDAY', + 'SUNDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 12, + 'minute': 50, + }), + 'isWeeklyTimer': False, + 'timerAction': 'ACTIVATE', + 'timerWeekDays': list([ + 'MONDAY', + ]), + }), + dict({ + 'departureTime': dict({ + 'hour': 18, + 'minute': 59, + }), + 'isWeeklyTimer': True, + 'timerAction': 'DEACTIVATE', + 'timerWeekDays': list([ + 'WEDNESDAY', + ]), + }), + ]), + 'combustionFuelLevel': dict({ + 'range': 105, + 'remainingFuelLiters': 6, + }), + 'currentMileage': 137009, + 'doorsState': dict({ + 'combinedSecurityState': 'UNLOCKED', + 'combinedState': 'CLOSED', + 'hood': 'CLOSED', + 'leftFront': 'CLOSED', + 'leftRear': 'CLOSED', + 'rightFront': 'CLOSED', + 'rightRear': 'CLOSED', + 'trunk': 'CLOSED', + }), + 'driverPreferences': dict({ + 'lscPrivacyMode': 'OFF', + }), + 'electricChargingState': dict({ + 'chargingConnectionType': 'CONDUCTIVE', + 'chargingLevelPercent': 82, + 'chargingStatus': 'WAITING_FOR_CHARGING', + 'chargingTarget': 100, + 'isChargerConnected': True, + 'range': 174, + }), + 'isLeftSteering': True, + 'isLscSupported': True, + 'lastFetched': '2022-06-22T14:24:23.982Z', + 'lastUpdatedAt': '2022-06-22T13:58:52Z', + 'range': 174, + 'requiredServices': list([ + dict({ + 'dateTime': '2022-10-01T00:00:00.000Z', + 'description': 'Next service due by the specified date.', + 'status': 'OK', + 'type': 'BRAKE_FLUID', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next vehicle check due after the specified distance or date.', + 'status': 'OK', + 'type': 'VEHICLE_CHECK', + }), + dict({ + 'dateTime': '2023-05-01T00:00:00.000Z', + 'description': 'Next state inspection due by the specified date.', + 'status': 'OK', + 'type': 'VEHICLE_TUV', + }), + ]), + 'roofState': dict({ + 'roofState': 'CLOSED', + 'roofStateType': 'SUN_ROOF', + }), + 'windowsState': dict({ + 'combinedState': 'CLOSED', + 'leftFront': 'CLOSED', + 'rightFront': 'CLOSED', }), - 'vin': '**REDACTED**', }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G26', - 'brand': 'BMW', - 'color': 4284245350, - 'countryOfOrigin': 'DE', - 'driveTrain': 'ELECTRIC', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID8', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'i4 eDrive40', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'G026', - }), - 'telematicsUnit': 'WAVE01', - 'year': 2021, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'DEMO', - 'attributes': dict({ - 'a4aType': 'NOT_SUPPORTED', - 'bodyType': 'G20', - 'brand': 'BMW', - 'color': 4280233344, - 'countryOfOrigin': 'PT', - 'driveTrain': 'COMBUSTION', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitRaw': 'HU_MGU', - 'headUnitType': 'MGU', - 'hmiVersion': 'ID7', - 'lastFetched': '2023-01-04T14:57:06.019Z', - 'model': 'M340i xDrive', - 'softwareVersionCurrent': dict({ - 'iStep': 470, - 'puStep': dict({ - 'month': 7, - 'year': 21, - }), - 'seriesCluster': 'S18A', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 420, - 'puStep': dict({ - 'month': 7, - 'year': 20, - }), - 'seriesCluster': 'S18A', - }), - 'telematicsUnit': 'ATM2', - 'year': 2022, - }), - 'mappingInfo': dict({ - 'isAssociated': False, - 'isLmmEnabled': False, - 'isPrimaryUser': True, - 'lmmStatusReasons': list([ - ]), - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - dict({ - 'appVehicleType': 'CONNECTED', - 'attributes': dict({ - 'a4aType': 'USB_ONLY', - 'bodyType': 'I01', - 'brand': 'BMW_I', - 'color': 4284110934, - 'countryOfOrigin': 'CZ', - 'driveTrain': 'ELECTRIC_WITH_RANGE_EXTENDER', - 'driverGuideInfo': dict({ - 'androidAppScheme': 'com.bmwgroup.driversguide.row', - 'androidStoreUrl': 'https://play.google.com/store/apps/details?id=com.bmwgroup.driversguide.row', - 'iosAppScheme': 'bmwdriversguide:///open', - 'iosStoreUrl': 'https://apps.apple.com/de/app/id714042749?mt=8', - }), - 'headUnitType': 'NBT', - 'hmiVersion': 'ID4', - 'lastFetched': '2022-06-01T19:48:46.540Z', - 'model': 'i3 (+ REX)', - 'softwareVersionCurrent': dict({ - 'iStep': 510, - 'puStep': dict({ - 'month': 11, - 'year': 21, - }), - 'seriesCluster': 'I001', - }), - 'softwareVersionExFactory': dict({ - 'iStep': 502, - 'puStep': dict({ - 'month': 3, - 'year': 15, - }), - 'seriesCluster': 'I001', - }), - 'year': 2015, - }), - 'mappingInfo': dict({ - 'isPrimaryUser': True, - 'mappingStatus': 'CONFIRMED', - }), - 'vin': '**REDACTED**', - }), - ]), - 'filename': 'bmw-eadrax-vcs_v4_vehicles.json', + }), + 'filename': 'bmw-eadrax-vcs_v4_vehicles_state_WBY0FINGERPRINT04.json', }), dict({ - 'content': list([ - ]), - 'filename': 'mini-eadrax-vcs_v4_vehicles.json', + 'content': dict({ + 'chargeAndClimateSettings': dict({ + 'chargeAndClimateTimer': dict({ + 'showDepartureTimers': False, + }), + }), + 'chargeAndClimateTimerDetail': dict({ + 'chargingMode': dict({ + 'chargingPreference': 'CHARGING_WINDOW', + 'endTimeSlot': '0001-01-01T01:30:00', + 'startTimeSlot': '0001-01-01T18:01:00', + 'type': 'TIME_SLOT', + }), + 'departureTimer': dict({ + 'type': 'WEEKLY_DEPARTURE_TIMER', + 'weeklyTimers': list([ + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + ]), + 'id': 1, + 'time': '0001-01-01T07:35:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + 'MONDAY', + 'TUESDAY', + 'WEDNESDAY', + 'THURSDAY', + 'FRIDAY', + 'SATURDAY', + 'SUNDAY', + ]), + 'id': 2, + 'time': '0001-01-01T18:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 3, + 'time': '0001-01-01T07:00:00', + 'timerAction': 'DEACTIVATE', + }), + dict({ + 'daysOfTheWeek': list([ + ]), + 'id': 4, + 'time': '0001-01-01T00:00:00', + 'timerAction': 'DEACTIVATE', + }), + ]), + }), + 'isPreconditionForDepartureActive': False, + }), + 'servicePack': 'TCB1', + }), + 'filename': 'bmw-eadrax-crccs_v2_vehicles_WBY0FINGERPRINT04.json', + }), + dict({ + 'content': dict({ + 'gcid': 'ceb64158-d2ca-47e9-9ee6-cbffb881434e', + 'mappingInfos': list([ + ]), + }), + 'filename': 'mini-eadrax-vcs_v5_vehicle-list.json', }), dict({ 'content': dict({ From 3351b826672f228257f232dd3122e2064db9f369 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 30 Apr 2024 15:07:15 +0200 Subject: [PATCH 202/272] Fix zoneminder async v2 (#116451) --- homeassistant/components/zoneminder/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py index b4a406cec4e..e87a2b1531d 100644 --- a/homeassistant/components/zoneminder/__init__.py +++ b/homeassistant/components/zoneminder/__init__.py @@ -78,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.data[DOMAIN][host_name] = zm_client try: - success = zm_client.login() and success + success = await hass.async_add_executor_job(zm_client.login) and success except RequestsConnectionError as ex: _LOGGER.error( "ZoneMinder connection failure to %s: %s", From c77cef039107d67e44a1dfb2f19d6befdd7ff759 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 11:53:55 -0500 Subject: [PATCH 203/272] Bump bluetooth-adapters to 0.19.1 (#116465) --- 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 ed1e11d8ddd..4bb84ab6dc3 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -16,7 +16,7 @@ "requirements": [ "bleak==0.21.1", "bleak-retry-connector==3.5.0", - "bluetooth-adapters==0.19.0", + "bluetooth-adapters==0.19.1", "bluetooth-auto-recovery==1.4.2", "bluetooth-data-tools==1.19.0", "dbus-fast==2.21.1", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a2eb0f1254c..4ba38346e83 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ awesomeversion==24.2.0 bcrypt==4.1.2 bleak-retry-connector==3.5.0 bleak==0.21.1 -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 bluetooth-auto-recovery==1.4.2 bluetooth-data-tools==1.19.0 cached_ipaddress==0.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 04097f0a9bc..8d926776063 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bbc60f01547..ab140526378 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -494,7 +494,7 @@ bluecurrent-api==1.2.3 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.19.0 +bluetooth-adapters==0.19.1 # homeassistant.components.bluetooth bluetooth-auto-recovery==1.4.2 From 3d13345575d490565001e72e5d8dcb513fd34cd8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:31:40 -0500 Subject: [PATCH 204/272] Ensure MQTT resubscribes happen before birth message (#116471) --- homeassistant/components/mqtt/client.py | 56 +++++++++++++++---------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 7f58a21a1f1..74fa8fb3302 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -878,6 +878,22 @@ class MQTT: await self._wait_for_mid(mid) + async def _async_resubscribe_and_publish_birth_message( + self, birth_message: PublishMessage + ) -> None: + """Resubscribe to all topics and publish birth message.""" + await self._async_perform_subscriptions() + await self._ha_started.wait() # Wait for Home Assistant to start + await self._discovery_cooldown() # Wait for MQTT discovery to cool down + # Update subscribe cooldown period to a shorter time + self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) + await self.async_publish( + topic=birth_message.topic, + payload=birth_message.payload, + qos=birth_message.qos, + retain=birth_message.retain, + ) + @callback def _async_mqtt_on_connect( self, @@ -919,36 +935,33 @@ class MQTT: result_code, ) - self.hass.async_create_task(self._async_resubscribe()) - + self._async_queue_resubscribe() + birth: dict[str, Any] if birth := self.conf.get(CONF_BIRTH_MESSAGE, DEFAULT_BIRTH): - - async def publish_birth_message(birth_message: PublishMessage) -> None: - await self._ha_started.wait() # Wait for Home Assistant to start - await self._discovery_cooldown() # Wait for MQTT discovery to cool down - # Update subscribe cooldown period to a shorter time - self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) - await self.async_publish( - topic=birth_message.topic, - payload=birth_message.payload, - qos=birth_message.qos, - retain=birth_message.retain, - ) - birth_message = PublishMessage(**birth) self.config_entry.async_create_background_task( self.hass, - publish_birth_message(birth_message), - name="mqtt birth message", + self._async_resubscribe_and_publish_birth_message(birth_message), + name="mqtt re-subscribe and birth", ) else: # Update subscribe cooldown period to a shorter time + self.config_entry.async_create_background_task( + self.hass, + self._async_perform_subscriptions(), + name="mqtt re-subscribe", + ) self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) self._async_connection_result(True) - async def _async_resubscribe(self) -> None: - """Resubscribe on reconnect.""" + @callback + def _async_queue_resubscribe(self) -> None: + """Queue subscriptions on reconnect. + + self._async_perform_subscriptions must be called + after this method to actually subscribe. + """ self._max_qos.clear() self._retained_topics.clear() # Group subscriptions to only re-subscribe once for each topic. @@ -963,7 +976,6 @@ class MQTT: ], queue_only=True, ) - await self._async_perform_subscriptions() @lru_cache(None) # pylint: disable=method-cache-max-size-none def _matching_subscriptions(self, topic: str) -> list[Subscription]: @@ -1052,7 +1064,9 @@ class MQTT: # The callback signature for on_unsubscribe is different from on_subscribe # see https://github.com/eclipse/paho.mqtt.python/issues/687 # properties and reasoncodes are not used in Home Assistant - self.hass.async_create_task(self._mqtt_handle_mid(mid)) + self.config_entry.async_create_task( + self.hass, self._mqtt_handle_mid(mid), name=f"mqtt handle mid {mid}" + ) async def _mqtt_handle_mid(self, mid: int) -> None: # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid From c574d86ddbafd6c18995ad9efb297fda3ce4292c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:31:09 -0500 Subject: [PATCH 205/272] Fix local_todo blocking the event loop (#116473) --- homeassistant/components/local_todo/todo.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/local_todo/todo.py b/homeassistant/components/local_todo/todo.py index 5b25abf8e21..ccd3d8db759 100644 --- a/homeassistant/components/local_todo/todo.py +++ b/homeassistant/components/local_todo/todo.py @@ -18,6 +18,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util import dt as dt_util from .const import CONF_TODO_LIST_NAME, DOMAIN @@ -67,9 +68,16 @@ async def async_setup_entry( ) -> None: """Set up the local_todo todo platform.""" - store = hass.data[DOMAIN][config_entry.entry_id] + store: LocalTodoListStore = hass.data[DOMAIN][config_entry.entry_id] ics = await store.async_load() - calendar = IcsCalendarStream.calendar_from_ics(ics) + + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # calendar_from_ics will dynamically load packages + # the first time it is called, so we need to do it + # in a separate thread to avoid blocking the event loop + calendar: Calendar = await hass.async_add_import_executor_job( + IcsCalendarStream.calendar_from_ics, ics + ) migrated = _migrate_calendar(calendar) calendar.prodid = PRODID From c54d53b88a1dd5f4dea719924d9e395bb6b51060 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:47:17 -0400 Subject: [PATCH 206/272] Change SkyConnect integration type back to `hardware` and fix multi-PAN migration bug (#116474) Co-authored-by: Joost Lekkerkerker --- .../homeassistant_sky_connect/config_flow.py | 15 ++++++++ .../homeassistant_sky_connect/manifest.json | 2 +- homeassistant/generated/integrations.json | 5 --- .../test_config_flow.py | 38 +++++++++++++++++++ 4 files changed, 54 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 6ffb2783165..9d0aa902cc4 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -597,6 +597,21 @@ class HomeAssistantSkyConnectMultiPanOptionsFlowHandler( """Return the name of the hardware.""" return self._hw_variant.full_name + async def async_step_flashing_complete( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Finish flashing and update the config entry.""" + self.hass.config_entries.async_update_entry( + entry=self.config_entry, + data={ + **self.config_entry.data, + "firmware": ApplicationType.EZSP.value, + }, + options=self.config_entry.options, + ) + + return await super().async_step_flashing_complete(user_input) + class HomeAssistantSkyConnectOptionsFlowHandler( BaseFirmwareInstallFlow, OptionsFlowWithConfigEntry diff --git a/homeassistant/components/homeassistant_sky_connect/manifest.json b/homeassistant/components/homeassistant_sky_connect/manifest.json index c90ea2c075f..f56fd24de61 100644 --- a/homeassistant/components/homeassistant_sky_connect/manifest.json +++ b/homeassistant/components/homeassistant_sky_connect/manifest.json @@ -5,7 +5,7 @@ "config_flow": true, "dependencies": ["hardware", "usb", "homeassistant_hardware"], "documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect", - "integration_type": "device", + "integration_type": "hardware", "usb": [ { "vid": "10C4", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index cf5f352f22c..e6a103989d1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2565,11 +2565,6 @@ "integration_type": "virtual", "supported_by": "netatmo" }, - "homeassistant_sky_connect": { - "name": "Home Assistant SkyConnect", - "integration_type": "device", - "config_flow": true - }, "homematic": { "name": "Homematic", "integrations": { diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index c34e3ebe186..611dda4a917 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -11,6 +11,8 @@ from universal_silabs_flasher.const import ApplicationType from homeassistant.components import usb from homeassistant.components.hassio.addon_manager import AddonInfo, AddonState from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + CONF_DISABLE_MULTI_PAN, + get_flasher_addon_manager, get_multiprotocol_addon_manager, ) from homeassistant.components.homeassistant_sky_connect.config_flow import ( @@ -869,11 +871,25 @@ async def test_options_flow_multipan_uninstall( version="1.0.0", ) + mock_flasher_manager = Mock(spec_set=get_flasher_addon_manager(hass)) + mock_flasher_manager.async_get_addon_info.return_value = AddonInfo( + available=True, + hostname=None, + options={}, + state=AddonState.NOT_RUNNING, + update_available=False, + version="1.0.0", + ) + with ( patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_multiprotocol_addon_manager", return_value=mock_multipan_manager, ), + patch( + "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.get_flasher_addon_manager", + return_value=mock_flasher_manager, + ), patch( "homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon.is_hassio", return_value=True, @@ -883,3 +899,25 @@ async def test_options_flow_multipan_uninstall( assert result["type"] is FlowResultType.MENU assert result["step_id"] == "addon_menu" assert "uninstall_addon" in result["menu_options"] + + # Pick the uninstall option + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"next_step_id": "uninstall_addon"}, + ) + + # Check the box + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_DISABLE_MULTI_PAN: True} + ) + + # Finish the flow + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + await hass.async_block_till_done(wait_background_tasks=True) + result = await hass.config_entries.options.async_configure(result["flow_id"]) + assert result["type"] is FlowResultType.CREATE_ENTRY + + # We've reverted the firmware back to Zigbee + assert config_entry.data["firmware"] == "ezsp" From 6971898a43deaefa94c0ad3e46864be90aaae819 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 15:47:27 -0500 Subject: [PATCH 207/272] Fix non-thread-safe operation in roon volume callback (#116475) --- homeassistant/components/roon/event.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/roon/event.py b/homeassistant/components/roon/event.py index ea5014c8755..073b58160f6 100644 --- a/homeassistant/components/roon/event.py +++ b/homeassistant/components/roon/event.py @@ -72,7 +72,6 @@ class RoonEventEntity(EventEntity): via_device=(DOMAIN, self._server.roon_id), ) - @callback def _roonapi_volume_callback( self, control_key: str, event: str, value: int ) -> None: @@ -88,7 +87,7 @@ class RoonEventEntity(EventEntity): event = "volume_down" self._trigger_event(event) - self.async_write_ha_state() + self.schedule_update_ha_state() async def async_added_to_hass(self) -> None: """Register volume hooks with the roon api.""" From 3d86577cabc2a6e645055e7ecc3d09db258317e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 May 2024 01:15:46 +0200 Subject: [PATCH 208/272] Add test MQTT subscription is completed when birth message is sent (#116476) --- tests/components/mqtt/test_init.py | 69 ++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index cfb8ce7ac04..f948889fd80 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2534,6 +2534,75 @@ async def test_delayed_birth_message( ) +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: { + mqtt.ATTR_TOPIC: "homeassistant/status", + mqtt.ATTR_PAYLOAD: "online", + mqtt.ATTR_QOS: 0, + mqtt.ATTR_RETAIN: False, + }, + } + ], +) +async def test_subscription_done_when_birth_message_is_sent( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_config_entry_data, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test sending birth message until initial subscription has been completed.""" + mqtt_mock = await mqtt_mock_entry() + + hass.set_state(CoreState.starting) + birth = asyncio.Event() + + await hass.async_block_till_done() + + entry = MockConfigEntry(domain=mqtt.DOMAIN, data=mqtt_config_entry_data) + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mqtt_component_mock = MagicMock( + return_value=hass.data["mqtt"].client, + wraps=hass.data["mqtt"].client, + ) + mqtt_component_mock._mqttc = mqtt_client_mock + + hass.data["mqtt"].client = mqtt_component_mock + mqtt_mock = hass.data["mqtt"].client + mqtt_mock.reset_mock() + + @callback + def wait_birth(msg: ReceiveMessage) -> None: + """Handle birth message.""" + birth.set() + + mqtt_client_mock.reset_mock() + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0): + await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) + mqtt_client_mock.on_connect(None, None, 0, 0) + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + # We wait until we receive a birth message + await asyncio.wait_for(birth.wait(), 1) + # Assert we already have subscribed at the client + # for new config payloads at the time we the birth message is received + assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls( + mqtt_client_mock + ) + assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls( + mqtt_client_mock + ) + mqtt_client_mock.publish.assert_called_with( + "homeassistant/status", "online", 0, False + ) + + @pytest.mark.parametrize( "mqtt_config_entry_data", [ From ac241057772073ed51f7816e064b74aef45326c1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 May 2024 00:46:25 +0200 Subject: [PATCH 209/272] Update frontend to 20240430.0 (#116481) --- 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 e271903a27d..aa1d8ee3d3c 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==20240429.0"] + "requirements": ["home-assistant-frontend==20240430.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4ba38346e83..afb7d894a51 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8d926776063..6e2b7a55a13 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab140526378..2d7fe7158bf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240429.0 +home-assistant-frontend==20240430.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From 7d51556e1ea06dba2892f36541666220d79dc3ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 30 Apr 2024 18:47:12 -0500 Subject: [PATCH 210/272] Hold a lock to prevent concurrent setup of config entries (#116482) --- homeassistant/config_entries.py | 30 +++-- homeassistant/setup.py | 2 +- .../androidtv_remote/test_config_flow.py | 3 + .../components/config/test_config_entries.py | 5 + tests/components/mqtt/test_init.py | 1 + tests/components/opower/test_config_flow.py | 2 + tests/test_config_entries.py | 104 +++++++++++++++++- 7 files changed, 129 insertions(+), 18 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 619b2a4b48a..f982f63b948 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -292,7 +292,7 @@ class ConfigEntry: update_listeners: list[UpdateListenerType] _async_cancel_retry_setup: Callable[[], Any] | None _on_unload: list[Callable[[], Coroutine[Any, Any, None] | None]] | None - reload_lock: asyncio.Lock + setup_lock: asyncio.Lock _reauth_lock: asyncio.Lock _reconfigure_lock: asyncio.Lock _tasks: set[asyncio.Future[Any]] @@ -400,7 +400,7 @@ class ConfigEntry: _setter(self, "_on_unload", None) # Reload lock to prevent conflicting reloads - _setter(self, "reload_lock", asyncio.Lock()) + _setter(self, "setup_lock", asyncio.Lock()) # Reauth lock to prevent concurrent reauth flows _setter(self, "_reauth_lock", asyncio.Lock()) # Reconfigure lock to prevent concurrent reconfigure flows @@ -699,19 +699,17 @@ class ConfigEntry: # has started so we do not block shutdown if not hass.is_stopping: hass.async_create_background_task( - self._async_setup_retry(hass), + self.async_setup_locked(hass), f"config entry retry {self.domain} {self.title}", eager_start=True, ) - async def _async_setup_retry(self, hass: HomeAssistant) -> None: - """Retry setup. - - We hold the reload lock during setup retry to ensure - that nothing can reload the entry while we are retrying. - """ - async with self.reload_lock: - await self.async_setup(hass) + async def async_setup_locked( + self, hass: HomeAssistant, integration: loader.Integration | None = None + ) -> None: + """Set up while holding the setup lock.""" + async with self.setup_lock: + await self.async_setup(hass, integration=integration) @callback def async_shutdown(self) -> None: @@ -1791,7 +1789,15 @@ class ConfigEntries: # attempts. entry.async_cancel_retry_setup() - async with entry.reload_lock: + if entry.domain not in self.hass.config.components: + # If the component is not loaded, just load it as + # the config entry will be loaded as well. We need + # to do this before holding the lock to avoid a + # deadlock. + await async_setup_component(self.hass, entry.domain, self._hass_config) + return entry.state is ConfigEntryState.LOADED + + async with entry.setup_lock: unload_result = await self.async_unload(entry_id) if not unload_result or entry.disabled_by: diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 7ba51b644e5..86df6417169 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -449,7 +449,7 @@ async def _async_setup_component( await asyncio.gather( *( create_eager_task( - entry.async_setup(hass, integration=integration), + entry.async_setup_locked(hass, integration=integration), name=f"config entry setup {entry.title} {entry.domain} {entry.entry_id}", ) for entry in entries diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index 8778630be8d..062b9a4a55c 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -324,6 +324,7 @@ async def test_user_flow_already_configured_host_changed_reloads_entry( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -640,6 +641,7 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) result = await hass.config_entries.flow.async_init( DOMAIN, @@ -769,6 +771,7 @@ async def test_reauth_flow_success( state=ConfigEntryState.LOADED, ) mock_config_entry.add_to_hass(hass) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index dd46921c339..87c712b3716 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -251,6 +251,7 @@ async def test_reload_entry(hass: HomeAssistant, client) -> None: domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED ) entry.add_to_hass(hass) + hass.config.components.add("kitchen_sink") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -298,6 +299,7 @@ async def test_reload_entry_in_failed_state( """Test reloading an entry via the API that has already failed to unload.""" entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.FAILED_UNLOAD) entry.add_to_hass(hass) + hass.config.components.add("demo") resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" ) @@ -326,6 +328,7 @@ async def test_reload_entry_in_setup_retry( entry = MockConfigEntry(domain="comp", state=core_ce.ConfigEntryState.SETUP_RETRY) entry.supports_unload = True entry.add_to_hass(hass) + hass.config.components.add("comp") with patch.dict(HANDLERS, {"comp": ConfigFlow, "test": ConfigFlow}): resp = await client.post( @@ -1109,6 +1112,7 @@ async def test_update_prefrences( domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED ) entry.add_to_hass(hass) + hass.config.components.add("kitchen_sink") assert entry.pref_disable_new_entities is False assert entry.pref_disable_polling is False @@ -1209,6 +1213,7 @@ async def test_disable_entry( ) entry.add_to_hass(hass) assert entry.disabled_by is None + hass.config.components.add("kitchen_sink") # Disable await ws_client.send_json( diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index f948889fd80..a1264b52739 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1873,6 +1873,7 @@ async def test_reload_entry_with_restored_subscriptions( # Setup the MQTT entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) + hass.config.components.add(mqtt.DOMAIN) mqtt_client_mock.connect.return_value = 0 with patch("homeassistant.config.load_yaml_config_file", return_value={}): await entry.async_setup(hass) diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index 512a602a043..18a7caf23df 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -279,6 +279,7 @@ async def test_form_valid_reauth( ) -> None: """Test that we can handle a valid reauth.""" mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() @@ -328,6 +329,7 @@ async def test_form_valid_reauth_with_mfa( }, ) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) await hass.async_block_till_done() diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 68f770631ed..8d7efad8918 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -825,7 +825,7 @@ async def test_as_dict(snapshot: SnapshotAssertion) -> None: "error_reason_translation_placeholders", "_async_cancel_retry_setup", "_on_unload", - "reload_lock", + "setup_lock", "_reauth_lock", "_tasks", "_background_tasks", @@ -1632,7 +1632,6 @@ async def test_entry_reload_succeed( mock_platform(hass, "comp.config_flow", None) assert await manager.async_reload(entry.entry_id) - assert len(async_unload_entry.mock_calls) == 1 assert len(async_setup.mock_calls) == 1 assert len(async_setup_entry.mock_calls) == 1 assert entry.state is config_entries.ConfigEntryState.LOADED @@ -1707,6 +1706,8 @@ async def test_entry_reload_error( ), ) + hass.config.components.add("comp") + with pytest.raises(config_entries.OperationNotAllowed, match=str(state)): assert await manager.async_reload(entry.entry_id) @@ -1738,8 +1739,11 @@ async def test_entry_disable_succeed( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") # Disable + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 assert await manager.async_set_disabled_by( entry.entry_id, config_entries.ConfigEntryDisabler.USER ) @@ -1751,7 +1755,7 @@ async def test_entry_disable_succeed( # Enable assert await manager.async_set_disabled_by(entry.entry_id, None) assert len(async_unload_entry.mock_calls) == 1 - assert len(async_setup.mock_calls) == 1 + assert len(async_setup.mock_calls) == 0 assert len(async_setup_entry.mock_calls) == 1 assert entry.state is config_entries.ConfigEntryState.LOADED @@ -1775,6 +1779,7 @@ async def test_entry_disable_without_reload_support( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") # Disable assert not await manager.async_set_disabled_by( @@ -1951,7 +1956,7 @@ async def test_reload_entry_entity_registry_works( ) await hass.async_block_till_done() - assert len(mock_unload_entry.mock_calls) == 2 + assert len(mock_unload_entry.mock_calls) == 1 async def test_unique_id_persisted( @@ -3392,6 +3397,7 @@ async def test_entry_reload_calls_on_unload_listeners( ), ) mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") mock_unload_callback = Mock() @@ -3944,8 +3950,9 @@ async def test_deprecated_disabled_by_str_set( caplog: pytest.LogCaptureFixture, ) -> None: """Test deprecated str set disabled_by enumizes and logs a warning.""" - entry = MockConfigEntry() + entry = MockConfigEntry(domain="comp") entry.add_to_manager(manager) + hass.config.components.add("comp") assert await manager.async_set_disabled_by( entry.entry_id, config_entries.ConfigEntryDisabler.USER.value ) @@ -3963,6 +3970,47 @@ async def test_entry_reload_concurrency( async_setup = AsyncMock(return_value=True) loaded = 1 + async def _async_setup_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded += 1 + return loaded == 1 + + async def _async_unload_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded -= 1 + return loaded == 0 + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=_async_setup_entry, + async_unload_entry=_async_unload_entry, + ), + ) + mock_platform(hass, "comp.config_flow", None) + hass.config.components.add("comp") + tasks = [ + asyncio.create_task(manager.async_reload(entry.entry_id)) for _ in range(15) + ] + await asyncio.gather(*tasks) + assert entry.state is config_entries.ConfigEntryState.LOADED + assert loaded == 1 + + +async def test_entry_reload_concurrency_not_setup_setup( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test multiple reload calls do not cause a reload race.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + loaded = 0 + async def _async_setup_entry(*args, **kwargs): await asyncio.sleep(0) nonlocal loaded @@ -4074,6 +4122,7 @@ async def test_disallow_entry_reload_with_setup_in_progress( domain="comp", state=config_entries.ConfigEntryState.SETUP_IN_PROGRESS ) entry.add_to_hass(hass) + hass.config.components.add("comp") with pytest.raises( config_entries.OperationNotAllowed, @@ -5016,3 +5065,48 @@ async def test_updating_non_added_entry_raises(hass: HomeAssistant) -> None: with pytest.raises(config_entries.UnknownEntry, match=entry.entry_id): hass.config_entries.async_update_entry(entry, unique_id="new_id") + + +async def test_reload_during_setup(hass: HomeAssistant) -> None: + """Test reload during setup waits.""" + entry = MockConfigEntry(domain="comp", data={"value": "initial"}) + entry.add_to_hass(hass) + + setup_start_future = hass.loop.create_future() + setup_finish_future = hass.loop.create_future() + in_setup = False + setup_calls = 0 + + async def mock_async_setup_entry(hass, entry): + """Mock setting up an entry.""" + nonlocal in_setup + nonlocal setup_calls + setup_calls += 1 + assert not in_setup + in_setup = True + setup_start_future.set_result(None) + await setup_finish_future + in_setup = False + return True + + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_async_setup_entry, + async_unload_entry=AsyncMock(return_value=True), + ), + ) + mock_platform(hass, "comp.config_flow", None) + + setup_task = hass.async_create_task(async_setup_component(hass, "comp", {})) + + await setup_start_future # ensure we are in the setup + reload_task = hass.async_create_task( + hass.config_entries.async_reload(entry.entry_id) + ) + await asyncio.sleep(0) + setup_finish_future.set_result(None) + await setup_task + await reload_task + assert setup_calls == 2 From 31cfabc44d8c340757865c6194c5f9b00173c306 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 1 May 2024 05:56:02 -0400 Subject: [PATCH 211/272] Fix roborock image crashes (#116487) --- .../components/roborock/coordinator.py | 2 +- homeassistant/components/roborock/image.py | 31 ++++++++-- tests/components/roborock/conftest.py | 4 ++ tests/components/roborock/test_image.py | 62 +++++++++++++++++++ 4 files changed, 92 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 293415360bd..32b7a487ac8 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -49,7 +49,7 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): ) device_data = DeviceData(device, product_info.model, device_networking.ip) self.api: RoborockLocalClientV1 | RoborockMqttClientV1 = RoborockLocalClientV1( - device_data + device_data, queue_timeout=5 ) self.cloud_api = cloud_api self.device_info = DeviceInfo( diff --git a/homeassistant/components/roborock/image.py b/homeassistant/components/roborock/image.py index 775ab98fd59..2aef39ce59b 100644 --- a/homeassistant/components/roborock/image.py +++ b/homeassistant/components/roborock/image.py @@ -66,17 +66,26 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): ) self._attr_image_last_updated = dt_util.utcnow() self.map_flag = map_flag - self.cached_map = self._create_image(starting_map) + try: + self.cached_map = self._create_image(starting_map) + except HomeAssistantError: + # If we failed to update the image on init, we set cached_map to empty bytes so that we are unavailable and can try again later. + self.cached_map = b"" self._attr_entity_category = EntityCategory.DIAGNOSTIC + @property + def available(self): + """Determines if the entity is available.""" + return self.cached_map != b"" + @property def is_selected(self) -> bool: """Return if this map is the currently selected map.""" return self.map_flag == self.coordinator.current_map def is_map_valid(self) -> bool: - """Update this map if it is the current active map, and the vacuum is cleaning.""" - return ( + """Update this map if it is the current active map, and the vacuum is cleaning or if it has never been set at all.""" + return self.cached_map == b"" or ( self.is_selected and self.image_last_updated is not None and self.coordinator.roborock_device_info.props.status is not None @@ -96,7 +105,16 @@ class RoborockMap(RoborockCoordinatedEntity, ImageEntity): async def async_image(self) -> bytes | None: """Update the image if it is not cached.""" if self.is_map_valid(): - map_data: bytes = await self.cloud_api.get_map_v1() + response = await asyncio.gather( + *(self.cloud_api.get_map_v1(), self.coordinator.get_rooms()), + return_exceptions=True, + ) + if not isinstance(response[0], bytes): + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="map_failure", + ) + map_data = response[0] self.cached_map = self._create_image(map_data) return self.cached_map @@ -141,9 +159,10 @@ async def create_coordinator_maps( await asyncio.sleep(MAP_SLEEP) # Get the map data map_update = await asyncio.gather( - *[coord.cloud_api.get_map_v1(), coord.get_rooms()] + *[coord.cloud_api.get_map_v1(), coord.get_rooms()], return_exceptions=True ) - api_data: bytes = map_update[0] + # If we fail to get the map -> We should set it to empty byte, still create it, and set it as unavailable. + api_data: bytes = map_update[0] if isinstance(map_update[0], bytes) else b"" entities.append( RoborockMap( f"{slugify(coord.roborock_device_info.device.duid)}_map_{map_info.name}", diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index 0f3689da161..d3bb0a221b1 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -91,6 +91,10 @@ def bypass_api_fixture() -> None: RoomMapping(18, "2362041"), ], ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + return_value=b"123", + ), ): yield diff --git a/tests/components/roborock/test_image.py b/tests/components/roborock/test_image.py index 445f90f4a05..bc45c6dec05 100644 --- a/tests/components/roborock/test_image.py +++ b/tests/components/roborock/test_image.py @@ -5,7 +5,12 @@ from datetime import timedelta from http import HTTPStatus from unittest.mock import patch +from roborock import RoborockException + +from homeassistant.components.roborock import DOMAIN +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -82,3 +87,60 @@ async def test_floorplan_image_failed_parse( async_fire_time_changed(hass, now) resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") assert not resp.ok + + +async def test_fail_parse_on_startup( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_roborock_entry: MockConfigEntry, + bypass_api_fixture, +) -> None: + """Test that if we fail parsing on startup, we create the entity but set it as unavailable.""" + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + with patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ): + await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert ( + image_entity := hass.states.get("image.roborock_s7_maxv_upstairs") + ) is not None + assert image_entity.state == STATE_UNAVAILABLE + + +async def test_fail_updating_image( + hass: HomeAssistant, + setup_entry: MockConfigEntry, + hass_client: ClientSessionGenerator, +) -> None: + """Test that we handle failing getting the image after it has already been setup..""" + client = await hass_client() + map_data = copy.deepcopy(MAP_DATA) + map_data.image = None + now = dt_util.utcnow() + timedelta(seconds=91) + # Copy the device prop so we don't override it + prop = copy.deepcopy(PROP) + prop.status.in_cleaning = 1 + # Update image, but get none for parse image. + with ( + patch( + "homeassistant.components.roborock.image.RoborockMapDataParser.parse", + return_value=map_data, + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClientV1.get_prop", + return_value=prop, + ), + patch( + "homeassistant.components.roborock.image.dt_util.utcnow", return_value=now + ), + patch( + "homeassistant.components.roborock.coordinator.RoborockMqttClientV1.get_map_v1", + side_effect=RoborockException, + ), + ): + async_fire_time_changed(hass, now) + resp = await client.get("/api/image_proxy/image.roborock_s7_maxv_upstairs") + assert not resp.ok From fabc3d751e01f4043a0c39c378fd3383bc80f700 Mon Sep 17 00:00:00 2001 From: max2697 <143563471+max2697@users.noreply.github.com> Date: Wed, 1 May 2024 00:11:47 -0500 Subject: [PATCH 212/272] Bump opower to 0.4.4 (#116489) --- 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 51ad669733b..91e4fbc960c 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.4.3"] + "requirements": ["opower==0.4.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6e2b7a55a13..2b9fbebcd8b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1483,7 +1483,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.4.3 +opower==0.4.4 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d7fe7158bf..28f1ffb7d62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1183,7 +1183,7 @@ openhomedevice==2.2.0 openwebifpy==4.2.4 # homeassistant.components.opower -opower==0.4.3 +opower==0.4.4 # homeassistant.components.oralb oralb-ble==0.17.6 From ad16c5bc254d8e95c4e3e14de6de9bbcf71e474b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 1 May 2024 12:53:45 +0200 Subject: [PATCH 213/272] Update frontend to 20240501.0 (#116503) --- 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 aa1d8ee3d3c..6abe8df1d7c 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==20240430.0"] + "requirements": ["home-assistant-frontend==20240501.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index afb7d894a51..b1c0391022a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ habluetooth==2.8.0 hass-nabucasa==0.78.0 hassil==1.6.1 home-assistant-bluetooth==1.12.0 -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 home-assistant-intents==2024.4.24 httpx==0.27.0 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 2b9fbebcd8b..e23a81ccb4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1078,7 +1078,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 28f1ffb7d62..2393cdd9db1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ hole==0.8.0 holidays==0.47 # homeassistant.components.frontend -home-assistant-frontend==20240430.0 +home-assistant-frontend==20240501.0 # homeassistant.components.conversation home-assistant-intents==2024.4.24 From 0eb734b6bfbfad902616fab30918d86bed126130 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 May 2024 13:41:34 +0200 Subject: [PATCH 214/272] Bump version to 2024.5.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 3a0d35b8324..1d0486c75c7 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 a04e9fd218a..7dcbc5afdd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b4" +version = "2024.5.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From c46be022c876520014fbb32ebdfa2507cd2e1a0a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 May 2024 14:38:36 +0200 Subject: [PATCH 215/272] Add IMGW-PIB integration (#116468) * Add sensor platform * Add tests * Fix icons.json * Use entry.runtime_data * Remove validate_input function * Change abort reason to cannot_connect * Remove unnecessary square brackets * Move _attr_attribution outside the constructor * Use native_value property * Use is with ENUMs * Import SOURCE_USER * Change test name * Use freezer.tick * Tests refactoring * Remove test_setup_entry * Test creating entry after error * Add missing async_block_till_done * Fix translation key * Remove coordinator type annotation * Enable strict typing * Assert config entry unique_id --------- Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/imgw_pib/__init__.py | 62 +++++ .../components/imgw_pib/config_flow.py | 84 +++++++ homeassistant/components/imgw_pib/const.py | 11 + .../components/imgw_pib/coordinator.py | 43 ++++ homeassistant/components/imgw_pib/icons.json | 12 + .../components/imgw_pib/manifest.json | 9 + homeassistant/components/imgw_pib/sensor.py | 97 ++++++++ .../components/imgw_pib/strings.json | 29 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/imgw_pib/__init__.py | 11 + tests/components/imgw_pib/conftest.py | 67 ++++++ .../imgw_pib/snapshots/test_sensor.ambr | 221 ++++++++++++++++++ tests/components/imgw_pib/test_config_flow.py | 96 ++++++++ tests/components/imgw_pib/test_init.py | 44 ++++ tests/components/imgw_pib/test_sensor.py | 65 ++++++ 21 files changed, 877 insertions(+) create mode 100644 homeassistant/components/imgw_pib/__init__.py create mode 100644 homeassistant/components/imgw_pib/config_flow.py create mode 100644 homeassistant/components/imgw_pib/const.py create mode 100644 homeassistant/components/imgw_pib/coordinator.py create mode 100644 homeassistant/components/imgw_pib/icons.json create mode 100644 homeassistant/components/imgw_pib/manifest.json create mode 100644 homeassistant/components/imgw_pib/sensor.py create mode 100644 homeassistant/components/imgw_pib/strings.json create mode 100644 tests/components/imgw_pib/__init__.py create mode 100644 tests/components/imgw_pib/conftest.py create mode 100644 tests/components/imgw_pib/snapshots/test_sensor.ambr create mode 100644 tests/components/imgw_pib/test_config_flow.py create mode 100644 tests/components/imgw_pib/test_init.py create mode 100644 tests/components/imgw_pib/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 584ccc5ee0a..28f484b3334 100644 --- a/.strict-typing +++ b/.strict-typing @@ -244,6 +244,7 @@ homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* homeassistant.components.imap.* +homeassistant.components.imgw_pib.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.input_text.* diff --git a/CODEOWNERS b/CODEOWNERS index fdea411d208..f1fb578155b 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -650,6 +650,8 @@ build.json @home-assistant/supervisor /tests/components/image_upload/ @home-assistant/core /homeassistant/components/imap/ @jbouwh /tests/components/imap/ @jbouwh +/homeassistant/components/imgw_pib/ @bieniu +/tests/components/imgw_pib/ @bieniu /homeassistant/components/improv_ble/ @emontnemery /tests/components/improv_ble/ @emontnemery /homeassistant/components/incomfort/ @zxdavb diff --git a/homeassistant/components/imgw_pib/__init__.py b/homeassistant/components/imgw_pib/__init__.py new file mode 100644 index 00000000000..f3dd66eb23d --- /dev/null +++ b/homeassistant/components/imgw_pib/__init__.py @@ -0,0 +1,62 @@ +"""The IMGW-PIB integration.""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging + +from aiohttp import ClientError +from imgw_pib import ImgwPib +from imgw_pib.exceptions import ApiError + +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_ID +from .coordinator import ImgwPibDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + +ImgwPibConfigEntry = ConfigEntry["ImgwPibData"] + + +@dataclass +class ImgwPibData: + """Data for the IMGW-PIB integration.""" + + coordinator: ImgwPibDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> bool: + """Set up IMGW-PIB from a config entry.""" + station_id: str = entry.data[CONF_STATION_ID] + + _LOGGER.debug("Using hydrological station ID: %s", station_id) + + client_session = async_get_clientsession(hass) + + try: + imgwpib = await ImgwPib.create( + client_session, hydrological_station_id=station_id + ) + except (ClientError, TimeoutError, ApiError) as err: + raise ConfigEntryNotReady from err + + coordinator = ImgwPibDataUpdateCoordinator(hass, imgwpib, station_id) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = ImgwPibData(coordinator) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ImgwPibConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/imgw_pib/config_flow.py b/homeassistant/components/imgw_pib/config_flow.py new file mode 100644 index 00000000000..558528fcbef --- /dev/null +++ b/homeassistant/components/imgw_pib/config_flow.py @@ -0,0 +1,84 @@ +"""Config flow for IMGW-PIB integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from aiohttp import ClientError +from imgw_pib import ImgwPib +from imgw_pib.exceptions import ApiError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_STATION_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ImgwPibFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow for IMGW-PIB.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + client_session = async_get_clientsession(self.hass) + + if user_input is not None: + station_id = user_input[CONF_STATION_ID] + + await self.async_set_unique_id(station_id, raise_on_progress=False) + self._abort_if_unique_id_configured() + + try: + imgwpib = await ImgwPib.create( + client_session, hydrological_station_id=station_id + ) + hydrological_data = await imgwpib.get_hydrological_data() + except (ClientError, TimeoutError, ApiError): + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + title = f"{hydrological_data.river} ({hydrological_data.station})" + return self.async_create_entry(title=title, data=user_input) + + try: + imgwpib = await ImgwPib.create(client_session) + await imgwpib.update_hydrological_stations() + except (ClientError, TimeoutError, ApiError): + return self.async_abort(reason="cannot_connect") + + options: list[SelectOptionDict] = [ + SelectOptionDict(value=station_id, label=station_name) + for station_id, station_name in imgwpib.hydrological_stations.items() + ] + + schema: vol.Schema = vol.Schema( + { + vol.Required(CONF_STATION_ID): SelectSelector( + SelectSelectorConfig( + options=options, + multiple=False, + sort=True, + mode=SelectSelectorMode.DROPDOWN, + ), + ) + } + ) + + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imgw_pib/const.py b/homeassistant/components/imgw_pib/const.py new file mode 100644 index 00000000000..41782ea059a --- /dev/null +++ b/homeassistant/components/imgw_pib/const.py @@ -0,0 +1,11 @@ +"""Constants for the IMGW-PIB integration.""" + +from datetime import timedelta + +DOMAIN = "imgw_pib" + +ATTRIBUTION = "Data provided by IMGW-PIB" + +CONF_STATION_ID = "station_id" + +UPDATE_INTERVAL = timedelta(minutes=30) diff --git a/homeassistant/components/imgw_pib/coordinator.py b/homeassistant/components/imgw_pib/coordinator.py new file mode 100644 index 00000000000..77a58001a6f --- /dev/null +++ b/homeassistant/components/imgw_pib/coordinator.py @@ -0,0 +1,43 @@ +"""Data Update Coordinator for IMGW-PIB integration.""" + +import logging + +from imgw_pib import ApiError, HydrologicalData, ImgwPib + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class ImgwPibDataUpdateCoordinator(DataUpdateCoordinator[HydrologicalData]): + """Class to manage fetching IMGW-PIB data API.""" + + def __init__( + self, + hass: HomeAssistant, + imgwpib: ImgwPib, + station_id: str, + ) -> None: + """Initialize.""" + self.imgwpib = imgwpib + self.station_id = station_id + self.device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, station_id)}, + manufacturer="IMGW-PIB", + name=f"{imgwpib.hydrological_stations[station_id]}", + configuration_url=f"https://hydro.imgw.pl/#/station/hydro/{station_id}", + ) + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> HydrologicalData: + """Update data via internal method.""" + try: + return await self.imgwpib.get_hydrological_data() + except ApiError as err: + raise UpdateFailed(err) from err diff --git a/homeassistant/components/imgw_pib/icons.json b/homeassistant/components/imgw_pib/icons.json new file mode 100644 index 00000000000..29aa19a4b56 --- /dev/null +++ b/homeassistant/components/imgw_pib/icons.json @@ -0,0 +1,12 @@ +{ + "entity": { + "sensor": { + "water_level": { + "default": "mdi:waves" + }, + "water_temperature": { + "default": "mdi:thermometer-water" + } + } + } +} diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json new file mode 100644 index 00000000000..63f6146be84 --- /dev/null +++ b/homeassistant/components/imgw_pib/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "imgw_pib", + "name": "IMGW-PIB", + "codeowners": ["@bieniu"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/imgw_pib", + "iot_class": "cloud_polling", + "requirements": ["imgw_pib==1.0.0"] +} diff --git a/homeassistant/components/imgw_pib/sensor.py b/homeassistant/components/imgw_pib/sensor.py new file mode 100644 index 00000000000..1df651faa52 --- /dev/null +++ b/homeassistant/components/imgw_pib/sensor.py @@ -0,0 +1,97 @@ +"""IMGW-PIB sensor platform.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from imgw_pib.model import HydrologicalData + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import UnitOfLength, UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import ImgwPibConfigEntry +from .const import ATTRIBUTION +from .coordinator import ImgwPibDataUpdateCoordinator + +PARALLEL_UPDATES = 1 + + +@dataclass(frozen=True, kw_only=True) +class ImgwPibSensorEntityDescription(SensorEntityDescription): + """IMGW-PIB sensor entity description.""" + + value: Callable[[HydrologicalData], StateType] + + +SENSOR_TYPES: tuple[ImgwPibSensorEntityDescription, ...] = ( + ImgwPibSensorEntityDescription( + key="water_level", + translation_key="water_level", + native_unit_of_measurement=UnitOfLength.CENTIMETERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=0, + value=lambda data: data.water_level.value, + ), + ImgwPibSensorEntityDescription( + key="water_temperature", + translation_key="water_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=1, + value=lambda data: data.water_temperature.value, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ImgwPibConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a IMGW-PIB sensor entity from a config_entry.""" + coordinator = entry.runtime_data.coordinator + + async_add_entities( + ImgwPibSensorEntity(coordinator, description) + for description in SENSOR_TYPES + if getattr(coordinator.data, description.key).value is not None + ) + + +class ImgwPibSensorEntity( + CoordinatorEntity[ImgwPibDataUpdateCoordinator], SensorEntity +): + """Define IMGW-PIB sensor entity.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + entity_description: ImgwPibSensorEntityDescription + + def __init__( + self, + coordinator: ImgwPibDataUpdateCoordinator, + description: ImgwPibSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._attr_unique_id = f"{coordinator.station_id}_{description.key}" + self._attr_device_info = coordinator.device_info + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/imgw_pib/strings.json b/homeassistant/components/imgw_pib/strings.json new file mode 100644 index 00000000000..9a17dcf7087 --- /dev/null +++ b/homeassistant/components/imgw_pib/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "station_id": "Hydrological station" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", + "cannot_connect": "Failed to connect" + } + }, + "entity": { + "sensor": { + "water_level": { + "name": "Water level" + }, + "water_temperature": { + "name": "Water temperature" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6f6ce237904..301715ad111 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -251,6 +251,7 @@ FLOWS = { "idasen_desk", "ifttt", "imap", + "imgw_pib", "improv_ble", "inkbird", "insteon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e6a103989d1..e1365820bf4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2782,6 +2782,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "imgw_pib": { + "name": "IMGW-PIB", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "improv_ble": { "name": "Improv via BLE", "integration_type": "device", diff --git a/mypy.ini b/mypy.ini index 611dd176fbf..08e4bcc0e4f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2202,6 +2202,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.imgw_pib.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 8c7caa60eef..69c1ee62b97 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1136,6 +1136,9 @@ iglo==1.2.7 # homeassistant.components.ihc ihcsdk==2.8.5 +# homeassistant.components.imgw_pib +imgw_pib==1.0.0 + # homeassistant.components.incomfort incomfort-client==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c06ed21f066..cb9c91037b0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -923,6 +923,9 @@ idasen-ha==2.5.1 # homeassistant.components.network ifaddr==0.2.0 +# homeassistant.components.imgw_pib +imgw_pib==1.0.0 + # homeassistant.components.influxdb influxdb-client==1.24.0 diff --git a/tests/components/imgw_pib/__init__.py b/tests/components/imgw_pib/__init__.py new file mode 100644 index 00000000000..c684b596949 --- /dev/null +++ b/tests/components/imgw_pib/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the IMGW-PIB integration.""" + +from tests.common import MockConfigEntry + + +async def init_integration(hass, config_entry: MockConfigEntry) -> MockConfigEntry: + """Set up the IMGW-PIB integration in Home Assistant.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/imgw_pib/conftest.py b/tests/components/imgw_pib/conftest.py new file mode 100644 index 00000000000..b22b8b68661 --- /dev/null +++ b/tests/components/imgw_pib/conftest.py @@ -0,0 +1,67 @@ +"""Common fixtures for the IMGW-PIB tests.""" + +from collections.abc import Generator +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +from imgw_pib import HydrologicalData, SensorData +import pytest + +from homeassistant.components.imgw_pib.const import DOMAIN + +from tests.common import MockConfigEntry + +HYDROLOGICAL_DATA = HydrologicalData( + station="Station Name", + river="River Name", + station_id="123", + water_level=SensorData(name="Water Level", value=526.0), + flood_alarm_level=SensorData(name="Flood Alarm Level", value=630.0), + flood_warning_level=SensorData(name="Flood Warning Level", value=590.0), + water_temperature=SensorData(name="Water Temperature", value=10.8), + flood_alarm=False, + flood_warning=False, + water_level_measurement_date=datetime(2024, 4, 27, 10, 0, tzinfo=UTC), + water_temperature_measurement_date=datetime(2024, 4, 27, 10, 10, tzinfo=UTC), +) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.imgw_pib.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_imgw_pib_client() -> Generator[AsyncMock, None, None]: + """Mock a ImgwPib client.""" + with ( + patch( + "homeassistant.components.imgw_pib.ImgwPib", autospec=True + ) as mock_client, + patch( + "homeassistant.components.imgw_pib.config_flow.ImgwPib", + new=mock_client, + ), + ): + client = mock_client.create.return_value + client.get_hydrological_data.return_value = HYDROLOGICAL_DATA + client.hydrological_stations = {"123": "River Name (Station Name)"} + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="River Name (Station Name)", + unique_id="123", + data={ + "station_id": "123", + }, + ) diff --git a/tests/components/imgw_pib/snapshots/test_sensor.ambr b/tests/components/imgw_pib/snapshots/test_sensor.ambr new file mode 100644 index 00000000000..0bce7c96d7c --- /dev/null +++ b/tests/components/imgw_pib/snapshots/test_sensor.ambr @@ -0,0 +1,221 @@ +# serializer version: 1 +# name: test_sensor[sensor.river_name_station_name_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': '123_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'River Name (Station Name) Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '526.0', + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.river_name_station_name_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '123_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.river_name_station_name_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'temperature', + 'friendly_name': 'River Name (Station Name) Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.river_name_station_name_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- +# name: test_sensor[sensor.station_name_water_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_name_water_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water level', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_level', + 'unique_id': '123_water_level', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.station_name_water_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'distance', + 'friendly_name': 'Station Name Water level', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_name_water_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '526.0', + }) +# --- +# name: test_sensor[sensor.station_name_water_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.station_name_water_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water temperature', + 'platform': 'imgw_pib', + 'previous_unique_id': None, + 'supported_features': 0, + 'translation_key': 'water_temperature', + 'unique_id': '123_water_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor[sensor.station_name_water_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by IMGW-PIB', + 'device_class': 'temperature', + 'friendly_name': 'Station Name Water temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.station_name_water_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.8', + }) +# --- diff --git a/tests/components/imgw_pib/test_config_flow.py b/tests/components/imgw_pib/test_config_flow.py new file mode 100644 index 00000000000..ac26ed4771c --- /dev/null +++ b/tests/components/imgw_pib/test_config_flow.py @@ -0,0 +1,96 @@ +"""Test the IMGW-PIB config flow.""" + +from unittest.mock import AsyncMock + +from aiohttp import ClientError +from imgw_pib.exceptions import ApiError +import pytest + +from homeassistant.components.imgw_pib.const import CONF_STATION_ID, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def test_create_entry( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_imgw_pib_client: AsyncMock +) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "River Name (Station Name)" + assert result["data"] == {CONF_STATION_ID: "123"} + assert result["context"]["unique_id"] == "123" + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("exc", [ApiError("API Error"), ClientError, TimeoutError]) +async def test_form_no_station_list( + hass: HomeAssistant, exc: Exception, mock_imgw_pib_client: AsyncMock +) -> None: + """Test aborting the flow when we cannot get the list of hydrological stations.""" + mock_imgw_pib_client.update_hydrological_stations.side_effect = exc + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (Exception, "unknown"), + (ApiError("API Error"), "cannot_connect"), + (ClientError, "cannot_connect"), + (TimeoutError, "cannot_connect"), + ], +) +async def test_form_with_exceptions( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_setup_entry: AsyncMock, + mock_imgw_pib_client: AsyncMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {} + + mock_imgw_pib_client.get_hydrological_data.side_effect = exc + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": base_error} + + mock_imgw_pib_client.get_hydrological_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_STATION_ID: "123"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "River Name (Station Name)" + assert result["data"] == {CONF_STATION_ID: "123"} + assert result["context"]["unique_id"] == "123" + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/imgw_pib/test_init.py b/tests/components/imgw_pib/test_init.py new file mode 100644 index 00000000000..17c80891b1e --- /dev/null +++ b/tests/components/imgw_pib/test_init.py @@ -0,0 +1,44 @@ +"""Test init of IMGW-PIB integration.""" + +from unittest.mock import AsyncMock + +from imgw_pib import ApiError + +from homeassistant.components.imgw_pib.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry + + +async def test_config_not_ready( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test for setup failure if the connection to the service fails.""" + mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") + + await init_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test successful unload of entry.""" + await init_integration(hass, mock_config_entry) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) diff --git a/tests/components/imgw_pib/test_sensor.py b/tests/components/imgw_pib/test_sensor.py new file mode 100644 index 00000000000..2d17f7246fc --- /dev/null +++ b/tests/components/imgw_pib/test_sensor.py @@ -0,0 +1,65 @@ +"""Test the IMGW-PIB sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from imgw_pib import ApiError +from syrupy import SnapshotAssertion + +from homeassistant.components.imgw_pib.const import UPDATE_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + +ENTITY_ID = "sensor.river_name_station_name_water_level" + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test states of the sensor.""" + with patch("homeassistant.components.imgw_pib.PLATFORMS", [Platform.SENSOR]): + await init_integration(hass, mock_config_entry) + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_availability( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_imgw_pib_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Ensure that we mark the entities unavailable correctly when service is offline.""" + await init_integration(hass, mock_config_entry) + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "526.0" + + mock_imgw_pib_client.get_hydrological_data.side_effect = ApiError("API Error") + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state == STATE_UNAVAILABLE + + mock_imgw_pib_client.get_hydrological_data.side_effect = None + freezer.tick(UPDATE_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state + assert state.state != STATE_UNAVAILABLE + assert state.state == "526.0" From 61a7bc7aab50226ff7feb4d3d7f6b29504fc8c1e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 08:22:50 -0500 Subject: [PATCH 216/272] Fix blocking I/O to import modules in mysensors (#116516) --- homeassistant/components/mysensors/gateway.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 0a037dfce31..11f27f8a108 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -10,7 +10,7 @@ import socket import sys from typing import Any -from mysensors import BaseAsyncGateway, Message, Sensor, mysensors +from mysensors import BaseAsyncGateway, Message, Sensor, get_const, mysensors import voluptuous as vol from homeassistant.components.mqtt import ( @@ -24,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -162,6 +163,12 @@ async def _get_gateway( ) -> BaseAsyncGateway | None: """Return gateway after setup of the gateway.""" + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # get_const will import a const module based on the version + # so we need to import it here to avoid it being imported + # in the event loop + await hass.async_add_import_executor_job(get_const, version) + if persistence_file is not None: # Interpret relative paths to be in hass config folder. # Absolute paths will be left as they are. From c39d3b501efc809effee734738266c90753c039d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 08:23:33 -0500 Subject: [PATCH 217/272] Fix non-thread-safe operations in ihc (#116513) --- homeassistant/components/ihc/service_functions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py index cfd91f0960c..61eba4791ac 100644 --- a/homeassistant/components/ihc/service_functions.py +++ b/homeassistant/components/ihc/service_functions.py @@ -90,24 +90,24 @@ def setup_service_functions(hass: HomeAssistant) -> None: ihc_controller = _get_controller(call) await async_pulse(hass, ihc_controller, ihc_id) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL, async_set_runtime_value_bool, schema=SET_RUNTIME_VALUE_BOOL_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_INT, async_set_runtime_value_int, schema=SET_RUNTIME_VALUE_INT_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, async_set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_PULSE, async_pulse_runtime_input, schema=PULSE_SCHEMA ) From 0b340e14773ebac9ee0d72b7e3729a0ace0ef109 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 15:42:53 +0200 Subject: [PATCH 218/272] Bump python matter server library to 5.10.0 (#116514) --- homeassistant/components/matter/entity.py | 3 --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index dcb3586934b..a47147e874a 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -107,9 +107,6 @@ class MatterEntity(Entity): attr_path_filter=attr_path, ) ) - await self.matter_client.subscribe_attribute( - self._endpoint.node.node_id, sub_paths - ) # subscribe to node (availability changes) self._unsubscribes.append( self.matter_client.subscribe_events( diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b3acc0d547c..20988e387fe 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.7.0"], + "requirements": ["python-matter-server==5.10.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 69c1ee62b97..ff055b9acd9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2260,7 +2260,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.7.0 +python-matter-server==5.10.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb9c91037b0..6c4be35b69c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1754,7 +1754,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.7.0 +python-matter-server==5.10.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 07e608d81579d361c9959036a70d5bab72627573 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 May 2024 10:00:17 -0400 Subject: [PATCH 219/272] Bump ZHA dependencies (#116509) --- 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 452f11db85b..b1511b2f5bb 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.2", + "bellows==0.38.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.115", diff --git a/requirements_all.txt b/requirements_all.txt index ff055b9acd9..8e0a2421ed1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -541,7 +541,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.38.2 +bellows==0.38.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c4be35b69c..f5a85dc6a36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.38.2 +bellows==0.38.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 From b7a138b02a987577a16c2a5daba0a6ee3b95d233 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 May 2024 16:22:25 +0200 Subject: [PATCH 220/272] Improve scrape strings (#116519) --- homeassistant/components/scrape/strings.json | 42 +++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 217e69b27df..9b534aed77b 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -21,13 +21,13 @@ "encoding": "Character encoding" }, "data_description": { - "resource": "The URL to the website that contains the value", - "authentication": "Type of the HTTP authentication. Either basic or digest", - "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", - "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website", - "encoding": "Character encoding to use. Defaults to UTF-8", - "payload": "Payload to use when method is POST" + "resource": "The URL to the website that contains the value.", + "authentication": "Type of the HTTP authentication. Either basic or digest.", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed.", + "headers": "Headers to use for the web request.", + "timeout": "Timeout for connection to website.", + "encoding": "Character encoding to use. Defaults to UTF-8.", + "payload": "Payload to use when method is POST." } }, "sensor": { @@ -36,19 +36,21 @@ "attribute": "Attribute", "index": "Index", "select": "Select", - "value_template": "Value Template", - "device_class": "Device Class", - "state_class": "State Class", - "unit_of_measurement": "Unit of Measurement" + "value_template": "Value template", + "availability": "Availability template", + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement" }, "data_description": { - "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", - "attribute": "Get value of an attribute on the selected tag", - "index": "Defines which of the elements returned by the CSS selector to use", - "value_template": "Defines a template to get the state of the sensor", - "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state_class of the sensor", - "unit_of_measurement": "Choose temperature measurement or create your own" + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.", + "attribute": "Get value of an attribute on the selected tag.", + "index": "Defines which of the elements returned by the CSS selector to use.", + "value_template": "Defines a template to get the state of the sensor.", + "availability": "Defines a template to get the availability of the sensor.", + "device_class": "The type/class of the sensor to set the icon in the frontend.", + "state_class": "The state_class of the sensor.", + "unit_of_measurement": "Choose unit of measurement or create your own." } } } @@ -70,6 +72,7 @@ "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]" @@ -79,6 +82,7 @@ "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]" @@ -91,6 +95,7 @@ "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]" @@ -100,6 +105,7 @@ "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]" From f89677cd763762f28206440a042006c2e9c9a428 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Wed, 1 May 2024 10:00:17 -0400 Subject: [PATCH 221/272] Bump ZHA dependencies (#116509) --- 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 452f11db85b..b1511b2f5bb 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.38.2", + "bellows==0.38.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.115", diff --git a/requirements_all.txt b/requirements_all.txt index e23a81ccb4d..8c653ec38e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -541,7 +541,7 @@ beautifulsoup4==4.12.3 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.38.2 +bellows==0.38.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2393cdd9db1..9a0477cea8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ base36==0.1.1 beautifulsoup4==4.12.3 # homeassistant.components.zha -bellows==0.38.2 +bellows==0.38.3 # homeassistant.components.bmw_connected_drive bimmer-connected[china]==0.15.2 From 4312f36dbe010ac1f9e087e900731cabcc57b5b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 08:23:33 -0500 Subject: [PATCH 222/272] Fix non-thread-safe operations in ihc (#116513) --- homeassistant/components/ihc/service_functions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/ihc/service_functions.py b/homeassistant/components/ihc/service_functions.py index cfd91f0960c..61eba4791ac 100644 --- a/homeassistant/components/ihc/service_functions.py +++ b/homeassistant/components/ihc/service_functions.py @@ -90,24 +90,24 @@ def setup_service_functions(hass: HomeAssistant) -> None: ihc_controller = _get_controller(call) await async_pulse(hass, ihc_controller, ihc_id) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL, async_set_runtime_value_bool, schema=SET_RUNTIME_VALUE_BOOL_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_INT, async_set_runtime_value_int, schema=SET_RUNTIME_VALUE_INT_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, async_set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA, ) - hass.services.async_register( + hass.services.register( DOMAIN, SERVICE_PULSE, async_pulse_runtime_input, schema=PULSE_SCHEMA ) From 082721e1ab0a1aefd71469e38b9bd44801b77662 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 15:42:53 +0200 Subject: [PATCH 223/272] Bump python matter server library to 5.10.0 (#116514) --- homeassistant/components/matter/entity.py | 3 --- homeassistant/components/matter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index dcb3586934b..a47147e874a 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -107,9 +107,6 @@ class MatterEntity(Entity): attr_path_filter=attr_path, ) ) - await self.matter_client.subscribe_attribute( - self._endpoint.node.node_id, sub_paths - ) # subscribe to node (availability changes) self._unsubscribes.append( self.matter_client.subscribe_events( diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index b3acc0d547c..20988e387fe 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -6,6 +6,6 @@ "dependencies": ["websocket_api"], "documentation": "https://www.home-assistant.io/integrations/matter", "iot_class": "local_push", - "requirements": ["python-matter-server==5.7.0"], + "requirements": ["python-matter-server==5.10.0"], "zeroconf": ["_matter._tcp.local.", "_matterc._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 8c653ec38e1..f391511e607 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2257,7 +2257,7 @@ python-kasa[speedups]==0.6.2.1 # python-lirc==1.2.3 # homeassistant.components.matter -python-matter-server==5.7.0 +python-matter-server==5.10.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9a0477cea8b..140741518d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1751,7 +1751,7 @@ python-juicenet==1.1.0 python-kasa[speedups]==0.6.2.1 # homeassistant.components.matter -python-matter-server==5.7.0 +python-matter-server==5.10.0 # homeassistant.components.xiaomi_miio python-miio==0.5.12 From 780a6b314ff39d3e6caec5edbbbcaea99217ed0d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 08:22:50 -0500 Subject: [PATCH 224/272] Fix blocking I/O to import modules in mysensors (#116516) --- homeassistant/components/mysensors/gateway.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 0a037dfce31..11f27f8a108 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -10,7 +10,7 @@ import socket import sys from typing import Any -from mysensors import BaseAsyncGateway, Message, Sensor, mysensors +from mysensors import BaseAsyncGateway, Message, Sensor, get_const, mysensors import voluptuous as vol from homeassistant.components.mqtt import ( @@ -24,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv +from homeassistant.setup import SetupPhases, async_pause_setup from homeassistant.util.unit_system import METRIC_SYSTEM from .const import ( @@ -162,6 +163,12 @@ async def _get_gateway( ) -> BaseAsyncGateway | None: """Return gateway after setup of the gateway.""" + with async_pause_setup(hass, SetupPhases.WAIT_IMPORT_PACKAGES): + # get_const will import a const module based on the version + # so we need to import it here to avoid it being imported + # in the event loop + await hass.async_add_import_executor_job(get_const, version) + if persistence_file is not None: # Interpret relative paths to be in hass config folder. # Absolute paths will be left as they are. From 15aa8949eee2b6f43d44d9e76e87e5ea05d497a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 May 2024 16:22:25 +0200 Subject: [PATCH 225/272] Improve scrape strings (#116519) --- homeassistant/components/scrape/strings.json | 42 +++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json index 217e69b27df..9b534aed77b 100644 --- a/homeassistant/components/scrape/strings.json +++ b/homeassistant/components/scrape/strings.json @@ -21,13 +21,13 @@ "encoding": "Character encoding" }, "data_description": { - "resource": "The URL to the website that contains the value", - "authentication": "Type of the HTTP authentication. Either basic or digest", - "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", - "headers": "Headers to use for the web request", - "timeout": "Timeout for connection to website", - "encoding": "Character encoding to use. Defaults to UTF-8", - "payload": "Payload to use when method is POST" + "resource": "The URL to the website that contains the value.", + "authentication": "Type of the HTTP authentication. Either basic or digest.", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed.", + "headers": "Headers to use for the web request.", + "timeout": "Timeout for connection to website.", + "encoding": "Character encoding to use. Defaults to UTF-8.", + "payload": "Payload to use when method is POST." } }, "sensor": { @@ -36,19 +36,21 @@ "attribute": "Attribute", "index": "Index", "select": "Select", - "value_template": "Value Template", - "device_class": "Device Class", - "state_class": "State Class", - "unit_of_measurement": "Unit of Measurement" + "value_template": "Value template", + "availability": "Availability template", + "device_class": "Device class", + "state_class": "State class", + "unit_of_measurement": "Unit of measurement" }, "data_description": { - "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", - "attribute": "Get value of an attribute on the selected tag", - "index": "Defines which of the elements returned by the CSS selector to use", - "value_template": "Defines a template to get the state of the sensor", - "device_class": "The type/class of the sensor to set the icon in the frontend", - "state_class": "The state_class of the sensor", - "unit_of_measurement": "Choose temperature measurement or create your own" + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details.", + "attribute": "Get value of an attribute on the selected tag.", + "index": "Defines which of the elements returned by the CSS selector to use.", + "value_template": "Defines a template to get the state of the sensor.", + "availability": "Defines a template to get the availability of the sensor.", + "device_class": "The type/class of the sensor to set the icon in the frontend.", + "state_class": "The state_class of the sensor.", + "unit_of_measurement": "Choose unit of measurement or create your own." } } } @@ -70,6 +72,7 @@ "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]" @@ -79,6 +82,7 @@ "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]" @@ -91,6 +95,7 @@ "index": "[%key:component::scrape::config::step::sensor::data::index%]", "select": "[%key:component::scrape::config::step::sensor::data::select%]", "value_template": "[%key:component::scrape::config::step::sensor::data::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data::unit_of_measurement%]" @@ -100,6 +105,7 @@ "attribute": "[%key:component::scrape::config::step::sensor::data_description::attribute%]", "index": "[%key:component::scrape::config::step::sensor::data_description::index%]", "value_template": "[%key:component::scrape::config::step::sensor::data_description::value_template%]", + "availability": "[%key:component::scrape::config::step::sensor::data_description::availability%]", "device_class": "[%key:component::scrape::config::step::sensor::data_description::device_class%]", "state_class": "[%key:component::scrape::config::step::sensor::data_description::state_class%]", "unit_of_measurement": "[%key:component::scrape::config::step::sensor::data_description::unit_of_measurement%]" From 1e4e891f0b95b373e6c593935721d906adc86e70 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 May 2024 16:24:03 +0200 Subject: [PATCH 226/272] Bump version to 2024.5.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 1d0486c75c7..3c3787c7e80 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 7dcbc5afdd5..118f2f91d2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b5" +version = "2024.5.0b6" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 51f9e661a4f5f1c3fbb27ace2f54faad3bfce5c9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 17:11:47 +0200 Subject: [PATCH 227/272] Workday only update once a day (#116419) * Workday only update once a day * Fix tests --- .../components/workday/binary_sensor.py | 44 ++++++++++++++++--- .../components/workday/test_binary_sensor.py | 38 ++++++++++------ 2 files changed, 62 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 04a3a2544c1..1963359bf0a 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import date, timedelta +from datetime import date, datetime, timedelta from typing import Final from holidays import ( @@ -15,13 +15,20 @@ import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_COUNTRY, CONF_LANGUAGE, CONF_NAME -from homeassistant.core import HomeAssistant, ServiceResponse, SupportsResponse +from homeassistant.core import ( + CALLBACK_TYPE, + HomeAssistant, + ServiceResponse, + SupportsResponse, + callback, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, ) +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.util import dt as dt_util, slugify @@ -201,6 +208,8 @@ class IsWorkdaySensor(BinarySensorEntity): _attr_has_entity_name = True _attr_name = None _attr_translation_key = DOMAIN + _attr_should_poll = False + unsub: CALLBACK_TYPE | None = None def __init__( self, @@ -248,11 +257,34 @@ class IsWorkdaySensor(BinarySensorEntity): return False - async def async_update(self) -> None: - """Get date and look whether it is a holiday.""" - self._attr_is_on = self.date_is_workday(dt_util.now()) + def get_next_interval(self, now: datetime) -> datetime: + """Compute next time an update should occur.""" + tomorrow = dt_util.as_local(now) + timedelta(days=1) + return dt_util.start_of_local_day(tomorrow) - async def check_date(self, check_date: date) -> ServiceResponse: + def _update_state_and_setup_listener(self) -> None: + """Update state and setup listener for next interval.""" + now = dt_util.utcnow() + self.update_data(now) + self.unsub = async_track_point_in_utc_time( + self.hass, self.point_in_time_listener, self.get_next_interval(now) + ) + + @callback + def point_in_time_listener(self, time_date: datetime) -> None: + """Get the latest data and update state.""" + self._update_state_and_setup_listener() + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Set up first update.""" + self._update_state_and_setup_listener() + + def update_data(self, now: datetime) -> None: + """Get date and look whether it is a holiday.""" + self._attr_is_on = self.date_is_workday(now) + + def check_date(self, check_date: date) -> ServiceResponse: """Service to check if date is workday or not.""" return {"workday": self.date_is_workday(check_date)} diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index a3fba852f60..e9f0e8023bc 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -1,6 +1,6 @@ """Tests the Home Assistant workday binary sensor.""" -from datetime import date, datetime +from datetime import date, datetime, timedelta from typing import Any from freezegun.api import FrozenDateTimeFactory @@ -41,31 +41,34 @@ from . import ( init_integration, ) +from tests.common import async_fire_time_changed + @pytest.mark.parametrize( - ("config", "expected_state"), + ("config", "expected_state", "expected_state_weekend"), [ - (TEST_CONFIG_NO_COUNTRY, "on"), - (TEST_CONFIG_WITH_PROVINCE, "off"), - (TEST_CONFIG_NO_PROVINCE, "off"), - (TEST_CONFIG_WITH_STATE, "on"), - (TEST_CONFIG_NO_STATE, "on"), - (TEST_CONFIG_EXAMPLE_1, "on"), - (TEST_CONFIG_EXAMPLE_2, "off"), - (TEST_CONFIG_TOMORROW, "off"), - (TEST_CONFIG_DAY_AFTER_TOMORROW, "off"), - (TEST_CONFIG_YESTERDAY, "on"), - (TEST_CONFIG_NO_LANGUAGE_CONFIGURED, "off"), + (TEST_CONFIG_NO_COUNTRY, "on", "off"), + (TEST_CONFIG_WITH_PROVINCE, "off", "off"), + (TEST_CONFIG_NO_PROVINCE, "off", "off"), + (TEST_CONFIG_WITH_STATE, "on", "off"), + (TEST_CONFIG_NO_STATE, "on", "off"), + (TEST_CONFIG_EXAMPLE_1, "on", "off"), + (TEST_CONFIG_EXAMPLE_2, "off", "off"), + (TEST_CONFIG_TOMORROW, "off", "off"), + (TEST_CONFIG_DAY_AFTER_TOMORROW, "off", "off"), + (TEST_CONFIG_YESTERDAY, "on", "off"), # Friday was good Friday + (TEST_CONFIG_NO_LANGUAGE_CONFIGURED, "off", "off"), ], ) async def test_setup( hass: HomeAssistant, config: dict[str, Any], expected_state: str, + expected_state_weekend: str, freezer: FrozenDateTimeFactory, ) -> None: """Test setup from various configs.""" - freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Monday + freezer.move_to(datetime(2022, 4, 15, 12, tzinfo=UTC)) # Friday await init_integration(hass, config) state = hass.states.get("binary_sensor.workday_sensor") @@ -78,6 +81,13 @@ async def test_setup( "days_offset": config["days_offset"], } + freezer.tick(timedelta(days=1)) # Saturday + async_fire_time_changed(hass) + + state = hass.states.get("binary_sensor.workday_sensor") + assert state is not None + assert state.state == expected_state_weekend + async def test_setup_with_invalid_province_from_yaml(hass: HomeAssistant) -> None: """Test setup invalid province with import.""" From 2ad6353bf8410f29f4b73abc8317e77df29b2f8a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 10:22:50 -0500 Subject: [PATCH 228/272] Fix stop event cleanup when reloading MQTT (#116525) --- homeassistant/components/mqtt/client.py | 42 ++++++++++--------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 99e7deedf7a..6c7e0934a4e 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -25,19 +25,12 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import ( - CALLBACK_TYPE, - CoreState, - Event, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util.async_ import create_eager_task @@ -428,25 +421,22 @@ class MQTT: UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes ) self._pending_unsubscribes: set[str] = set() # topic - - if self.hass.state is CoreState.running: - self._ha_started.set() - else: - - @callback - def ha_started(_: Event) -> None: - self._ha_started.set() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - - async def async_stop_mqtt(_event: Event) -> None: - """Stop MQTT component.""" - await self.async_disconnect() - - self._cleanup_on_unload.append( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) + self._cleanup_on_unload.extend( + ( + async_at_started(hass, self._async_ha_started), + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop), + ) ) + @callback + def _async_ha_started(self, _hass: HomeAssistant) -> None: + """Handle HA started.""" + self._ha_started.set() + + async def _async_ha_stop(self, _event: Event) -> None: + """Handle HA stop.""" + await self.async_disconnect() + def start( self, mqtt_data: MqttData, From 9e8f7b56181178c60b8053a6435b582efa851d5c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 May 2024 17:28:20 +0200 Subject: [PATCH 229/272] Store GIOS runtime data in entry (#116510) Use entry.runtime_data Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- homeassistant/components/gios/__init__.py | 24 ++++++++++++-------- homeassistant/components/gios/diagnostics.py | 8 +++---- homeassistant/components/gios/sensor.py | 8 +++---- 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/gios/__init__.py b/homeassistant/components/gios/__init__.py index 5810a32f80f..6c49ddd9020 100644 --- a/homeassistant/components/gios/__init__.py +++ b/homeassistant/components/gios/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass import logging from aiohttp import ClientSession @@ -25,8 +26,17 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +GiosConfigEntry = ConfigEntry["GiosData"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +@dataclass +class GiosData: + """Data for GIOS integration.""" + + coordinator: GiosDataUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> bool: """Set up GIOS as config entry.""" station_id: int = entry.data[CONF_STATION_ID] _LOGGER.debug("Using station_id: %d", station_id) @@ -48,8 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = GiosDataUpdateCoordinator(hass, websession, station_id) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = GiosData(coordinator) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -65,14 +74,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: GiosConfigEntry) -> 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) class GiosDataUpdateCoordinator(DataUpdateCoordinator[GiosSensors]): # pylint: disable=hass-enforce-coordinator-module diff --git a/homeassistant/components/gios/diagnostics.py b/homeassistant/components/gios/diagnostics.py index 0bdd8f3a7ef..a94a95254de 100644 --- a/homeassistant/components/gios/diagnostics.py +++ b/homeassistant/components/gios/diagnostics.py @@ -5,18 +5,16 @@ from __future__ import annotations from dataclasses import asdict from typing import Any -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import GiosDataUpdateCoordinator -from .const import DOMAIN +from . import GiosConfigEntry async def async_get_config_entry_diagnostics( - hass: HomeAssistant, config_entry: ConfigEntry + hass: HomeAssistant, config_entry: GiosConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: GiosDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = config_entry.runtime_data.coordinator return { "config_entry": config_entry.as_dict(), diff --git a/homeassistant/components/gios/sensor.py b/homeassistant/components/gios/sensor.py index c2da9239453..244e741a086 100644 --- a/homeassistant/components/gios/sensor.py +++ b/homeassistant/components/gios/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -24,7 +23,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import GiosDataUpdateCoordinator +from . import GiosConfigEntry, GiosDataUpdateCoordinator from .const import ( ATTR_AQI, ATTR_C6H6, @@ -159,13 +158,12 @@ SENSOR_TYPES: tuple[GiosSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: GiosConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Add a GIOS entities from a config_entry.""" name = entry.data[CONF_NAME] - coordinator = hass.data[DOMAIN][entry.entry_id] - + coordinator = entry.runtime_data.coordinator # Due to the change of the attribute name of one sensor, it is necessary to migrate # the unique_id to the new name. entity_registry = er.async_get(hass) From 2fe17acaf7a74628ec0f4352d77c34fe4fd9c480 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 11:01:54 -0500 Subject: [PATCH 230/272] Bump yalexs to 3.1.0 (#116511) changelog: https://github.com/bdraco/yalexs/compare/v3.0.1...v3.1.0 --- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index e380a00cbc0..f85e75664eb 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -28,5 +28,5 @@ "documentation": "https://www.home-assistant.io/integrations/august", "iot_class": "cloud_push", "loggers": ["pubnub", "yalexs"], - "requirements": ["yalexs==3.0.1", "yalexs-ble==2.4.2"] + "requirements": ["yalexs==3.1.0", "yalexs-ble==2.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8e0a2421ed1..d6a2635596b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2908,7 +2908,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.4.2 # homeassistant.components.august -yalexs==3.0.1 +yalexs==3.1.0 # homeassistant.components.yeelight yeelight==0.7.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5a85dc6a36..efe5be7cdfc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2261,7 +2261,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.4.2 # homeassistant.components.august -yalexs==3.0.1 +yalexs==3.1.0 # homeassistant.components.yeelight yeelight==0.7.14 From 25df41475a0f4fe3f5e4db7282690aa4f6b4cb97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 11:03:10 -0500 Subject: [PATCH 231/272] Simplify MQTT mid handling (#116522) * Simplify MQTT mid handling switch from asyncio.Event to asyncio.Future * preen * preen * preen --- homeassistant/components/mqtt/client.py | 61 ++++++++++++------------- 1 file changed, 29 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 6c7e0934a4e..5eb85b1679c 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -405,8 +405,7 @@ class MQTT: self._cleanup_on_unload: list[Callable[[], None]] = [] self._connection_lock = asyncio.Lock() - self._pending_operations: dict[int, asyncio.Event] = {} - self._pending_operations_condition = asyncio.Condition() + self._pending_operations: dict[int, asyncio.Future[None]] = {} self._subscribe_debouncer = EnsureJobAfterCooldown( INITIAL_SUBSCRIBE_COOLDOWN, self._async_perform_subscriptions ) @@ -679,10 +678,6 @@ class MQTT: async def async_disconnect(self) -> None: """Stop the MQTT client.""" - def no_more_acks() -> bool: - """Return False if there are unprocessed ACKs.""" - return not any(not op.is_set() for op in self._pending_operations.values()) - # stop waiting for any pending subscriptions await self._subscribe_debouncer.async_cleanup() # reset timeout to initial subscribe cooldown @@ -693,8 +688,8 @@ class MQTT: await self._async_perform_unsubscribes() # wait for ACKs to be processed - async with self._pending_operations_condition: - await self._pending_operations_condition.wait_for(no_more_acks) + if pending := self._pending_operations.values(): + await asyncio.wait(pending) # stop the MQTT loop async with self._connection_lock: @@ -1050,24 +1045,21 @@ class MQTT: """Publish / Subscribe / Unsubscribe callback.""" # The callback signature for on_unsubscribe is different from on_subscribe # see https://github.com/eclipse/paho.mqtt.python/issues/687 - # properties and reasoncodes are not used in Home Assistant - self.config_entry.async_create_task( - self.hass, self._mqtt_handle_mid(mid), name=f"mqtt handle mid {mid}" - ) + # properties and reason codes are not used in Home Assistant + future = self._async_get_mid_future(mid) + if future.done(): + _LOGGER.warning("Received duplicate mid: %s", mid) + return + future.set_result(None) - async def _mqtt_handle_mid(self, mid: int) -> None: - # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid - # may be executed first. - async with self._pending_operations_condition: - if mid not in self._pending_operations: - self._pending_operations[mid] = asyncio.Event() - self._pending_operations[mid].set() - - async def _register_mid(self, mid: int) -> None: - """Create Event for an expected ACK.""" - async with self._pending_operations_condition: - if mid not in self._pending_operations: - self._pending_operations[mid] = asyncio.Event() + @callback + def _async_get_mid_future(self, mid: int) -> asyncio.Future[None]: + """Get the future for a mid.""" + if future := self._pending_operations.get(mid): + return future + future = self.hass.loop.create_future() + self._pending_operations[mid] = future + return future @callback def _async_mqtt_on_disconnect( @@ -1098,23 +1090,28 @@ class MQTT: result_code, ) + @callback + def _async_timeout_mid(self, future: asyncio.Future[None]) -> None: + """Timeout waiting for a mid.""" + if not future.done(): + future.set_exception(asyncio.TimeoutError) + async def _wait_for_mid(self, mid: int) -> None: """Wait for ACK from broker.""" # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid # may be executed first. - await self._register_mid(mid) + future = self._async_get_mid_future(mid) + loop = self.hass.loop + timer_handle = loop.call_later(TIMEOUT_ACK, self._async_timeout_mid, future) try: - async with asyncio.timeout(TIMEOUT_ACK): - await self._pending_operations[mid].wait() + await future except TimeoutError: _LOGGER.warning( "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid ) finally: - async with self._pending_operations_condition: - # Cleanup ACK sync buffer - del self._pending_operations[mid] - self._pending_operations_condition.notify_all() + timer_handle.cancel() + del self._pending_operations[mid] async def _discovery_cooldown(self) -> None: """Wait until all discovery and subscriptions are processed.""" From 0b08ae7e44738cef5ac1443bdc122c900820cedd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 11:04:20 -0500 Subject: [PATCH 232/272] Reduce timestamp function call overhead in core states (#116517) * Reduce timestamp function call overhead in core states The recorder or the websocket_api will always call the timestamps, so we will set the timestamp values when creating the State to avoid the function call overhead in the property we know will always be called. * Reduce timestamp function call overhead in core states The recorder or the websocket_api will always call the timestamps, so we will set the timestamp values when creating the State to avoid the function call overhead in the property we know will always be called. * reduce scope of change since last_reported is not called in websocket_api * reduce scope of change since last_reported is not called in websocket_api --- homeassistant/core.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 73d0e82fa83..40d6a544713 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1790,6 +1790,12 @@ class State: self.context = context or Context() self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) + # The recorder or the websocket_api will always call the timestamps, + # so we will set the timestamp values here to avoid the overhead of + # the function call in the property we know will always be called. + self.last_updated_timestamp = self.last_updated.timestamp() + if self.last_changed == self.last_updated: + self.__dict__["last_changed_timestamp"] = self.last_updated_timestamp @cached_property def name(self) -> str: @@ -1801,8 +1807,6 @@ class State: @cached_property def last_changed_timestamp(self) -> float: """Timestamp of last change.""" - if self.last_changed == self.last_updated: - return self.last_updated_timestamp return self.last_changed.timestamp() @cached_property @@ -1812,11 +1816,6 @@ class State: return self.last_updated_timestamp return self.last_reported.timestamp() - @cached_property - def last_updated_timestamp(self) -> float: - """Timestamp of last update.""" - return self.last_updated.timestamp() - @cached_property def _as_dict(self) -> dict[str, Any]: """Return a dict representation of the State. From 1db770ab3a12a8a0907f3fc4a0f05b68f195e4e8 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 18:30:59 +0200 Subject: [PATCH 233/272] Add blocklist for known Matter devices with faulty transitions (#116524) --- homeassistant/components/matter/light.py | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c9556fd2e2e..9d80ebc38f6 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -43,6 +43,18 @@ COLOR_MODE_MAP = { } DEFAULT_TRANSITION = 0.2 +# there's a bug in (at least) Espressif's implementation of light transitions +# on devices based on Matter 1.0. Mark potential devices with this issue. +# https://github.com/home-assistant/core/issues/113775 +# vendorid (attributeKey 0/40/2) +# productid (attributeKey 0/40/4) +# hw version (attributeKey 0/40/8) +# sw version (attributeKey 0/40/10) +TRANSITION_BLOCKLIST = ( + (4488, 514, "1.0", "1.0.0"), + (5010, 769, "3.0", "1.0.0"), +) + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +73,7 @@ class MatterLight(MatterEntity, LightEntity): _supports_brightness = False _supports_color = False _supports_color_temperature = False + _transitions_disabled = False async def _set_xy_color( self, xy_color: tuple[float, float], transition: float = 0.0 @@ -260,6 +273,8 @@ class MatterLight(MatterEntity, LightEntity): color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) transition = kwargs.get(ATTR_TRANSITION, DEFAULT_TRANSITION) + if self._transitions_disabled: + transition = 0 if self.supported_color_modes is not None: if hs_color is not None and ColorMode.HS in self.supported_color_modes: @@ -336,8 +351,12 @@ class MatterLight(MatterEntity, LightEntity): supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes + self._check_transition_blocklist() # flag support for transition as soon as we support setting brightness and/or color - if supported_color_modes != {ColorMode.ONOFF}: + if ( + supported_color_modes != {ColorMode.ONOFF} + and not self._transitions_disabled + ): self._attr_supported_features |= LightEntityFeature.TRANSITION LOGGER.debug( @@ -376,6 +395,21 @@ class MatterLight(MatterEntity, LightEntity): else: self._attr_color_mode = ColorMode.ONOFF + def _check_transition_blocklist(self) -> None: + """Check if this device is reported to have non working transitions.""" + device_info = self._endpoint.device_info + if ( + device_info.vendorID, + device_info.productID, + device_info.hardwareVersionString, + device_info.softwareVersionString, + ) in TRANSITION_BLOCKLIST: + self._transitions_disabled = True + LOGGER.warning( + "Detected a device that has been reported to have firmware issues " + "with light transitions. Transitions will be disabled for this light" + ) + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ From b42f3671288c9b7948792886c2051b34d6927597 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 1 May 2024 18:30:59 +0200 Subject: [PATCH 234/272] Add blocklist for known Matter devices with faulty transitions (#116524) --- homeassistant/components/matter/light.py | 36 +++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index c9556fd2e2e..9d80ebc38f6 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -43,6 +43,18 @@ COLOR_MODE_MAP = { } DEFAULT_TRANSITION = 0.2 +# there's a bug in (at least) Espressif's implementation of light transitions +# on devices based on Matter 1.0. Mark potential devices with this issue. +# https://github.com/home-assistant/core/issues/113775 +# vendorid (attributeKey 0/40/2) +# productid (attributeKey 0/40/4) +# hw version (attributeKey 0/40/8) +# sw version (attributeKey 0/40/10) +TRANSITION_BLOCKLIST = ( + (4488, 514, "1.0", "1.0.0"), + (5010, 769, "3.0", "1.0.0"), +) + async def async_setup_entry( hass: HomeAssistant, @@ -61,6 +73,7 @@ class MatterLight(MatterEntity, LightEntity): _supports_brightness = False _supports_color = False _supports_color_temperature = False + _transitions_disabled = False async def _set_xy_color( self, xy_color: tuple[float, float], transition: float = 0.0 @@ -260,6 +273,8 @@ class MatterLight(MatterEntity, LightEntity): color_temp = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) transition = kwargs.get(ATTR_TRANSITION, DEFAULT_TRANSITION) + if self._transitions_disabled: + transition = 0 if self.supported_color_modes is not None: if hs_color is not None and ColorMode.HS in self.supported_color_modes: @@ -336,8 +351,12 @@ class MatterLight(MatterEntity, LightEntity): supported_color_modes = filter_supported_color_modes(supported_color_modes) self._attr_supported_color_modes = supported_color_modes + self._check_transition_blocklist() # flag support for transition as soon as we support setting brightness and/or color - if supported_color_modes != {ColorMode.ONOFF}: + if ( + supported_color_modes != {ColorMode.ONOFF} + and not self._transitions_disabled + ): self._attr_supported_features |= LightEntityFeature.TRANSITION LOGGER.debug( @@ -376,6 +395,21 @@ class MatterLight(MatterEntity, LightEntity): else: self._attr_color_mode = ColorMode.ONOFF + def _check_transition_blocklist(self) -> None: + """Check if this device is reported to have non working transitions.""" + device_info = self._endpoint.device_info + if ( + device_info.vendorID, + device_info.productID, + device_info.hardwareVersionString, + device_info.softwareVersionString, + ) in TRANSITION_BLOCKLIST: + self._transitions_disabled = True + LOGGER.warning( + "Detected a device that has been reported to have firmware issues " + "with light transitions. Transitions will be disabled for this light" + ) + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ From e1c08959b0d5dfa8fcacfb1e1d68c712c14ee81f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 10:22:50 -0500 Subject: [PATCH 235/272] Fix stop event cleanup when reloading MQTT (#116525) --- homeassistant/components/mqtt/client.py | 42 ++++++++++--------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 74fa8fb3302..d79492ccb27 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -25,19 +25,12 @@ from homeassistant.const import ( CONF_PORT, CONF_PROTOCOL, CONF_USERNAME, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import ( - CALLBACK_TYPE, - CoreState, - Event, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util @@ -429,25 +422,22 @@ class MQTT: UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes ) self._pending_unsubscribes: set[str] = set() # topic - - if self.hass.state is CoreState.running: - self._ha_started.set() - else: - - @callback - def ha_started(_: Event) -> None: - self._ha_started.set() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - - async def async_stop_mqtt(_event: Event) -> None: - """Stop MQTT component.""" - await self.async_disconnect() - - self._cleanup_on_unload.append( - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt) + self._cleanup_on_unload.extend( + ( + async_at_started(hass, self._async_ha_started), + hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop), + ) ) + @callback + def _async_ha_started(self, _hass: HomeAssistant) -> None: + """Handle HA started.""" + self._ha_started.set() + + async def _async_ha_stop(self, _event: Event) -> None: + """Handle HA stop.""" + await self.async_disconnect() + def start( self, mqtt_data: MqttData, From 1641f24314d6181e1f91ed61fd90e8e112342406 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 May 2024 18:32:41 +0200 Subject: [PATCH 236/272] Bump version to 2024.5.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 3c3787c7e80..38457221bc9 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -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 118f2f91d2c..57489b42fec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b6" +version = "2024.5.0b7" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 858874f0dafc274d27414d0761fda39142714078 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 1 May 2024 18:59:07 +0200 Subject: [PATCH 237/272] Bump version to 2024.5.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 38457221bc9..eb46817bd34 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2024 MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "0b7" +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 57489b42fec..4dd5653f8ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2024.5.0b7" +version = "2024.5.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 573cd8e94a5e061651897673fcc3ad00dfe33d45 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 12:45:47 -0500 Subject: [PATCH 238/272] Ensure mock mqtt handler is cleaned up after test_bootstrap_dependencies (#116544) --- tests/test_bootstrap.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 96caf5d10c8..782b082e639 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1143,16 +1143,10 @@ async def test_bootstrap_empty_integrations( await hass.async_block_till_done() -@pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) -@pytest.mark.parametrize("load_registries", [False]) -async def test_bootstrap_dependencies( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - integration: str, -) -> None: - """Test dependencies are set up correctly,.""" +@pytest.fixture(name="mock_mqtt_config_flow") +def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: + """Mock MQTT config flow.""" - # Prepare MQTT config entry @HANDLERS.register("mqtt") class MockConfigFlow: """Mock the MQTT config flow.""" @@ -1160,6 +1154,19 @@ async def test_bootstrap_dependencies( VERSION = 1 MINOR_VERSION = 1 + yield + HANDLERS.pop("mqtt") + + +@pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_dependencies( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + integration: str, + mock_mqtt_config_flow: None, +) -> None: + """Test dependencies are set up correctly,.""" entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) entry.add_to_hass(hass) From 21466180aad4ca1d0fb1487007c6e2419ba0ca77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 12:45:47 -0500 Subject: [PATCH 239/272] Ensure mock mqtt handler is cleaned up after test_bootstrap_dependencies (#116544) --- tests/test_bootstrap.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 96caf5d10c8..782b082e639 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1143,16 +1143,10 @@ async def test_bootstrap_empty_integrations( await hass.async_block_till_done() -@pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) -@pytest.mark.parametrize("load_registries", [False]) -async def test_bootstrap_dependencies( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - integration: str, -) -> None: - """Test dependencies are set up correctly,.""" +@pytest.fixture(name="mock_mqtt_config_flow") +def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: + """Mock MQTT config flow.""" - # Prepare MQTT config entry @HANDLERS.register("mqtt") class MockConfigFlow: """Mock the MQTT config flow.""" @@ -1160,6 +1154,19 @@ async def test_bootstrap_dependencies( VERSION = 1 MINOR_VERSION = 1 + yield + HANDLERS.pop("mqtt") + + +@pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) +@pytest.mark.parametrize("load_registries", [False]) +async def test_bootstrap_dependencies( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + integration: str, + mock_mqtt_config_flow: None, +) -> None: + """Test dependencies are set up correctly,.""" entry = MockConfigEntry(domain="mqtt", data={"broker": "test-broker"}) entry.add_to_hass(hass) From f73c55b4342deec215c465596460e01c4d1e5f2f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 13:22:18 -0500 Subject: [PATCH 240/272] Ensure mqtt handler is restored if its already registered in bootstrap test (#116549) --- tests/test_bootstrap.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 782b082e639..3d2735d9c1c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1146,6 +1146,7 @@ async def test_bootstrap_empty_integrations( @pytest.fixture(name="mock_mqtt_config_flow") def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: """Mock MQTT config flow.""" + original_mqtt = HANDLERS.get("mqtt") @HANDLERS.register("mqtt") class MockConfigFlow: @@ -1155,7 +1156,10 @@ def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: MINOR_VERSION = 1 yield - HANDLERS.pop("mqtt") + if original_mqtt: + HANDLERS["mqtt"] = original_mqtt + else: + HANDLERS.pop("mqtt") @pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) From 343d97527c52bc2a4b18e322aab5dfdbd080ecb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 13:22:18 -0500 Subject: [PATCH 241/272] Ensure mqtt handler is restored if its already registered in bootstrap test (#116549) --- tests/test_bootstrap.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 782b082e639..3d2735d9c1c 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -1146,6 +1146,7 @@ async def test_bootstrap_empty_integrations( @pytest.fixture(name="mock_mqtt_config_flow") def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: """Mock MQTT config flow.""" + original_mqtt = HANDLERS.get("mqtt") @HANDLERS.register("mqtt") class MockConfigFlow: @@ -1155,7 +1156,10 @@ def mock_mqtt_config_flow_fixture() -> Generator[None, None, None]: MINOR_VERSION = 1 yield - HANDLERS.pop("mqtt") + if original_mqtt: + HANDLERS["mqtt"] = original_mqtt + else: + HANDLERS.pop("mqtt") @pytest.mark.parametrize("integration", ["mqtt_eventstream", "mqtt_statestream"]) From c5cac8fed4541a4781daa675dce0ffba14d2fd31 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 1 May 2024 20:51:39 +0200 Subject: [PATCH 242/272] Store runtime data inside the config entry in AVM Fritz!Smarthome (#116523) --- homeassistant/components/fritzbox/__init__.py | 63 +++++-------------- .../components/fritzbox/binary_sensor.py | 9 +-- homeassistant/components/fritzbox/button.py | 9 +-- homeassistant/components/fritzbox/climate.py | 9 +-- homeassistant/components/fritzbox/common.py | 16 ----- homeassistant/components/fritzbox/const.py | 3 - .../components/fritzbox/coordinator.py | 39 +++++++++--- homeassistant/components/fritzbox/cover.py | 9 +-- .../components/fritzbox/diagnostics.py | 9 +-- homeassistant/components/fritzbox/light.py | 9 +-- homeassistant/components/fritzbox/sensor.py | 9 +-- homeassistant/components/fritzbox/switch.py | 9 +-- tests/components/fritzbox/conftest.py | 2 +- tests/components/fritzbox/test_init.py | 4 +- 14 files changed, 85 insertions(+), 114 deletions(-) delete mode 100644 homeassistant/components/fritzbox/common.py diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py index 904a86d21ae..460e1edd851 100644 --- a/homeassistant/components/fritzbox/__init__.py +++ b/homeassistant/components/fritzbox/__init__.py @@ -4,52 +4,23 @@ from __future__ import annotations from abc import ABC, abstractmethod -from pyfritzhome import Fritzhome, FritzhomeDevice, LoginError +from pyfritzhome import FritzhomeDevice from pyfritzhome.devicetypes.fritzhomeentitybase import FritzhomeEntityBase -from requests.exceptions import ConnectionError as RequestConnectionError from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, - UnitOfTemperature, -) +from homeassistant.const import EVENT_HOMEASSISTANT_STOP, UnitOfTemperature from homeassistant.core import Event, HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceEntry, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import CONF_CONNECTIONS, CONF_COORDINATOR, DOMAIN, LOGGER, PLATFORMS -from .coordinator import FritzboxDataUpdateCoordinator +from .const import DOMAIN, LOGGER, PLATFORMS +from .coordinator import FritzboxConfigEntry, FritzboxDataUpdateCoordinator -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> bool: """Set up the AVM FRITZ!SmartHome platforms.""" - fritz = Fritzhome( - host=entry.data[CONF_HOST], - user=entry.data[CONF_USERNAME], - password=entry.data[CONF_PASSWORD], - ) - - try: - await hass.async_add_executor_job(fritz.login) - except RequestConnectionError as err: - raise ConfigEntryNotReady from err - except LoginError as err: - raise ConfigEntryAuthFailed from err - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_CONNECTIONS: fritz, - } - - has_templates = await hass.async_add_executor_job(fritz.has_templates) - LOGGER.debug("enable smarthome templates: %s", has_templates) def _update_unique_id(entry: RegistryEntry) -> dict[str, str] | None: """Update unique ID of entity entry.""" @@ -73,15 +44,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_migrate_entries(hass, entry.entry_id, _update_unique_id) - coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id, has_templates) + coordinator = FritzboxDataUpdateCoordinator(hass, entry.entry_id) await coordinator.async_setup() - hass.data[DOMAIN][entry.entry_id][CONF_COORDINATOR] = coordinator + + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) def logout_fritzbox(event: Event) -> None: """Close connections to this fritzbox.""" - fritz.logout() + coordinator.fritz.logout() entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzbox) @@ -90,25 +62,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry(hass: HomeAssistant, entry: FritzboxConfigEntry) -> bool: """Unloading the AVM FRITZ!SmartHome platforms.""" - fritz = hass.data[DOMAIN][entry.entry_id][CONF_CONNECTIONS] - await hass.async_add_executor_job(fritz.logout) + await hass.async_add_executor_job(entry.runtime_data.fritz.logout) - 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_remove_config_entry_device( - hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry + hass: HomeAssistant, entry: FritzboxConfigEntry, device: DeviceEntry ) -> bool: """Remove Fritzbox config entry from a device.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - CONF_COORDINATOR - ] + coordinator = entry.runtime_data for identifier in device.identifiers: if identifier[0] == DOMAIN and ( diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py index 08fddc8a0ae..89394d35fe5 100644 --- a/homeassistant/components/fritzbox/binary_sensor.py +++ b/homeassistant/components/fritzbox/binary_sensor.py @@ -13,13 +13,12 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .common import get_coordinator +from .coordinator import FritzboxConfigEntry from .model import FritzEntityDescriptionMixinBase @@ -65,10 +64,12 @@ BINARY_SENSOR_TYPES: Final[tuple[FritzBinarySensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome binary sensor from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/button.py b/homeassistant/components/fritzbox/button.py index f3ea03f91b2..7ef91a74252 100644 --- a/homeassistant/components/fritzbox/button.py +++ b/homeassistant/components/fritzbox/button.py @@ -3,21 +3,22 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from homeassistant.components.button import ButtonEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxEntity -from .common import get_coordinator from .const import DOMAIN +from .coordinator import FritzboxConfigEntry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome template from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(templates: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py index de9ec200e3e..cfaa7a298ad 100644 --- a/homeassistant/components/fritzbox/climate.py +++ b/homeassistant/components/fritzbox/climate.py @@ -12,7 +12,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, @@ -23,7 +22,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .common import get_coordinator from .const import ( ATTR_STATE_BATTERY_LOW, ATTR_STATE_HOLIDAY_MODE, @@ -31,6 +29,7 @@ from .const import ( ATTR_STATE_WINDOW_OPEN, LOGGER, ) +from .coordinator import FritzboxConfigEntry from .model import ClimateExtraAttributes OPERATION_LIST = [HVACMode.HEAT, HVACMode.OFF] @@ -48,10 +47,12 @@ OFF_REPORT_SET_TEMPERATURE = 0.0 async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome thermostat from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/common.py b/homeassistant/components/fritzbox/common.py deleted file mode 100644 index ab87a51f9ce..00000000000 --- a/homeassistant/components/fritzbox/common.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Common functions for fritzbox integration.""" - -from homeassistant.core import HomeAssistant - -from .const import CONF_COORDINATOR, DOMAIN -from .coordinator import FritzboxDataUpdateCoordinator - - -def get_coordinator( - hass: HomeAssistant, config_entry_id: str -) -> FritzboxDataUpdateCoordinator: - """Get coordinator for given config entry id.""" - coordinator: FritzboxDataUpdateCoordinator = hass.data[DOMAIN][config_entry_id][ - CONF_COORDINATOR - ] - return coordinator diff --git a/homeassistant/components/fritzbox/const.py b/homeassistant/components/fritzbox/const.py index d664bd3a8d4..99ab173c21f 100644 --- a/homeassistant/components/fritzbox/const.py +++ b/homeassistant/components/fritzbox/const.py @@ -15,9 +15,6 @@ ATTR_STATE_WINDOW_OPEN: Final = "window_open" COLOR_MODE: Final = "1" COLOR_TEMP_MODE: Final = "4" -CONF_CONNECTIONS: Final = "connections" -CONF_COORDINATOR: Final = "coordinator" - DEFAULT_HOST: Final = "fritz.box" DEFAULT_USERNAME: Final = "admin" diff --git a/homeassistant/components/fritzbox/coordinator.py b/homeassistant/components/fritzbox/coordinator.py index 54af8fbdacd..abe1d2553f1 100644 --- a/homeassistant/components/fritzbox/coordinator.py +++ b/homeassistant/components/fritzbox/coordinator.py @@ -10,12 +10,15 @@ from pyfritzhome.devicetypes import FritzhomeTemplate from requests.exceptions import ConnectionError as RequestConnectionError, HTTPError from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_CONNECTIONS, DOMAIN, LOGGER +from .const import DOMAIN, LOGGER + +FritzboxConfigEntry = ConfigEntry["FritzboxDataUpdateCoordinator"] @dataclass @@ -29,10 +32,12 @@ class FritzboxCoordinatorData: class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorData]): """Fritzbox Smarthome device data update coordinator.""" - config_entry: ConfigEntry + config_entry: FritzboxConfigEntry configuration_url: str + fritz: Fritzhome + has_templates: bool - def __init__(self, hass: HomeAssistant, name: str, has_templates: bool) -> None: + def __init__(self, hass: HomeAssistant, name: str) -> None: """Initialize the Fritzbox Smarthome device coordinator.""" super().__init__( hass, @@ -41,11 +46,6 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat update_interval=timedelta(seconds=30), ) - self.fritz: Fritzhome = hass.data[DOMAIN][self.config_entry.entry_id][ - CONF_CONNECTIONS - ] - self.configuration_url = self.fritz.get_prefixed_host() - self.has_templates = has_templates self.new_devices: set[str] = set() self.new_templates: set[str] = set() @@ -53,6 +53,27 @@ class FritzboxDataUpdateCoordinator(DataUpdateCoordinator[FritzboxCoordinatorDat async def async_setup(self) -> None: """Set up the coordinator.""" + + self.fritz = Fritzhome( + host=self.config_entry.data[CONF_HOST], + user=self.config_entry.data[CONF_USERNAME], + password=self.config_entry.data[CONF_PASSWORD], + ) + + try: + await self.hass.async_add_executor_job(self.fritz.login) + except RequestConnectionError as err: + raise ConfigEntryNotReady from err + except LoginError as err: + raise ConfigEntryAuthFailed from err + + self.has_templates = await self.hass.async_add_executor_job( + self.fritz.has_templates + ) + LOGGER.debug("enable smarthome templates: %s", self.has_templates) + + self.configuration_url = self.fritz.get_prefixed_host() + await self.async_config_entry_first_refresh() self.cleanup_removed_devices( list(self.data.devices) + list(self.data.templates) diff --git a/homeassistant/components/fritzbox/cover.py b/homeassistant/components/fritzbox/cover.py index bd80b5f4af1..7a74d0b8184 100644 --- a/homeassistant/components/fritzbox/cover.py +++ b/homeassistant/components/fritzbox/cover.py @@ -10,19 +10,20 @@ from homeassistant.components.cover import ( CoverEntity, CoverEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .common import get_coordinator +from .coordinator import FritzboxConfigEntry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome cover from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/diagnostics.py b/homeassistant/components/fritzbox/diagnostics.py index 93e560e3117..cee4233e458 100644 --- a/homeassistant/components/fritzbox/diagnostics.py +++ b/homeassistant/components/fritzbox/diagnostics.py @@ -5,22 +5,19 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import CONF_COORDINATOR, DOMAIN -from .coordinator import FritzboxDataUpdateCoordinator +from .coordinator import FritzboxConfigEntry TO_REDACT = {CONF_USERNAME, CONF_PASSWORD} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: FritzboxConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data: dict = hass.data[DOMAIN][entry.entry_id] - coordinator: FritzboxDataUpdateCoordinator = data[CONF_COORDINATOR] + coordinator = entry.runtime_data diag_data = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/fritzbox/light.py b/homeassistant/components/fritzbox/light.py index dbc09beb235..689e64c709a 100644 --- a/homeassistant/components/fritzbox/light.py +++ b/homeassistant/components/fritzbox/light.py @@ -13,22 +13,23 @@ from homeassistant.components.light import ( ColorMode, LightEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzboxDataUpdateCoordinator, FritzBoxDeviceEntity -from .common import get_coordinator from .const import COLOR_MODE, COLOR_TEMP_MODE, LOGGER +from .coordinator import FritzboxConfigEntry SUPPORTED_COLOR_MODES = {ColorMode.COLOR_TEMP, ColorMode.HS} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome light from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 29f61d6e466..d28727c01f5 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -16,7 +16,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( PERCENTAGE, EntityCategory, @@ -32,7 +31,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.util.dt import utc_from_timestamp from . import FritzBoxDeviceEntity -from .common import get_coordinator +from .coordinator import FritzboxConfigEntry from .model import FritzEntityDescriptionMixinBase @@ -210,10 +209,12 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome sensor from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index b7ad08785f4..0bdf7a9f944 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -5,19 +5,20 @@ from __future__ import annotations from typing import Any from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import FritzBoxDeviceEntity -from .common import get_coordinator +from .coordinator import FritzboxConfigEntry async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: FritzboxConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the FRITZ!SmartHome switch from ConfigEntry.""" - coordinator = get_coordinator(hass, entry.entry_id) + coordinator = entry.runtime_data @callback def _add_entities(devices: set[str] | None = None) -> None: diff --git a/tests/components/fritzbox/conftest.py b/tests/components/fritzbox/conftest.py index 836a8bc127f..63e922f5836 100644 --- a/tests/components/fritzbox/conftest.py +++ b/tests/components/fritzbox/conftest.py @@ -9,7 +9,7 @@ import pytest def fritz_fixture() -> Mock: """Patch libraries.""" with ( - patch("homeassistant.components.fritzbox.Fritzhome") as fritz, + patch("homeassistant.components.fritzbox.coordinator.Fritzhome") as fritz, patch("homeassistant.components.fritzbox.config_flow.Fritzhome"), ): fritz.return_value.get_prefixed_host.return_value = "http://1.2.3.4" diff --git a/tests/components/fritzbox/test_init.py b/tests/components/fritzbox/test_init.py index f0391a03fb7..c84498b1560 100644 --- a/tests/components/fritzbox/test_init.py +++ b/tests/components/fritzbox/test_init.py @@ -254,7 +254,7 @@ async def test_raise_config_entry_not_ready_when_offline(hass: HomeAssistant) -> ) entry.add_to_hass(hass) with patch( - "homeassistant.components.fritzbox.Fritzhome.login", + "homeassistant.components.fritzbox.coordinator.Fritzhome.login", side_effect=RequestConnectionError(), ) as mock_login: await hass.config_entries.async_setup(entry.entry_id) @@ -275,7 +275,7 @@ async def test_raise_config_entry_error_when_login_fail(hass: HomeAssistant) -> ) entry.add_to_hass(hass) with patch( - "homeassistant.components.fritzbox.Fritzhome.login", + "homeassistant.components.fritzbox.coordinator.Fritzhome.login", side_effect=LoginError("user"), ) as mock_login: await hass.config_entries.async_setup(entry.entry_id) From ae6a497cd1864c8fdb0d5afc90f944022e323883 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 1 May 2024 21:06:22 +0200 Subject: [PATCH 243/272] Add diagnostics platform to IMGW-PIB integration (#116551) * Add diagnostics * Add tests --------- Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com> --- .../components/imgw_pib/diagnostics.py | 22 ++++++++ .../imgw_pib/snapshots/test_diagnostics.ambr | 50 +++++++++++++++++++ tests/components/imgw_pib/test_diagnostics.py | 31 ++++++++++++ 3 files changed, 103 insertions(+) create mode 100644 homeassistant/components/imgw_pib/diagnostics.py create mode 100644 tests/components/imgw_pib/snapshots/test_diagnostics.ambr create mode 100644 tests/components/imgw_pib/test_diagnostics.py diff --git a/homeassistant/components/imgw_pib/diagnostics.py b/homeassistant/components/imgw_pib/diagnostics.py new file mode 100644 index 00000000000..d135208115f --- /dev/null +++ b/homeassistant/components/imgw_pib/diagnostics.py @@ -0,0 +1,22 @@ +"""Diagnostics support for IMGW-PIB.""" + +from __future__ import annotations + +from dataclasses import asdict +from typing import Any + +from homeassistant.core import HomeAssistant + +from . import ImgwPibConfigEntry + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ImgwPibConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator = entry.runtime_data.coordinator + + return { + "config_entry_data": entry.as_dict(), + "hydrological_data": asdict(coordinator.data), + } diff --git a/tests/components/imgw_pib/snapshots/test_diagnostics.ambr b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000..096e370ab02 --- /dev/null +++ b/tests/components/imgw_pib/snapshots/test_diagnostics.ambr @@ -0,0 +1,50 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'config_entry_data': dict({ + 'data': dict({ + 'station_id': '123', + }), + 'disabled_by': None, + 'domain': 'imgw_pib', + 'minor_version': 1, + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'River Name (Station Name)', + 'unique_id': '123', + 'version': 1, + }), + 'hydrological_data': dict({ + 'flood_alarm': False, + 'flood_alarm_level': dict({ + 'name': 'Flood Alarm Level', + 'unit': None, + 'value': 630.0, + }), + 'flood_warning': False, + 'flood_warning_level': dict({ + 'name': 'Flood Warning Level', + 'unit': None, + 'value': 590.0, + }), + 'river': 'River Name', + 'station': 'Station Name', + 'station_id': '123', + 'water_level': dict({ + 'name': 'Water Level', + 'unit': None, + 'value': 526.0, + }), + 'water_level_measurement_date': '2024-04-27T10:00:00+00:00', + 'water_temperature': dict({ + 'name': 'Water Temperature', + 'unit': None, + 'value': 10.8, + }), + 'water_temperature_measurement_date': '2024-04-27T10:10:00+00:00', + }), + }) +# --- diff --git a/tests/components/imgw_pib/test_diagnostics.py b/tests/components/imgw_pib/test_diagnostics.py new file mode 100644 index 00000000000..62dabc982c4 --- /dev/null +++ b/tests/components/imgw_pib/test_diagnostics.py @@ -0,0 +1,31 @@ +"""Test the IMGW-PIB diagnostics platform.""" + +from unittest.mock import AsyncMock + +from syrupy import SnapshotAssertion +from syrupy.filters import props + +from homeassistant.core import HomeAssistant + +from . import init_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_config_entry: MockConfigEntry, + mock_imgw_pib_client: AsyncMock, +) -> None: + """Test config entry diagnostics.""" + await init_integration(hass, mock_config_entry) + + result = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert result == snapshot(exclude=props("entry_id")) From ef2ae7b60018cfb17c4b7649934501b1efce0256 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 21:13:29 +0200 Subject: [PATCH 244/272] Use runtime data in Yale Smart Alarm (#116548) --- .../components/yale_smart_alarm/__init__.py | 16 ++++++---------- .../yale_smart_alarm/alarm_control_panel.py | 10 ++++------ .../components/yale_smart_alarm/binary_sensor.py | 9 +++------ .../components/yale_smart_alarm/button.py | 9 +++------ .../components/yale_smart_alarm/const.py | 1 - .../components/yale_smart_alarm/diagnostics.py | 10 +++------- .../components/yale_smart_alarm/lock.py | 9 +++------ 7 files changed, 22 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py index 94728ee020c..c914e3c316f 100644 --- a/homeassistant/components/yale_smart_alarm/__init__.py +++ b/homeassistant/components/yale_smart_alarm/__init__.py @@ -9,11 +9,13 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import entity_registry as er -from .const import COORDINATOR, DOMAIN, LOGGER, PLATFORMS +from .const import LOGGER, PLATFORMS from .coordinator import YaleDataUpdateCoordinator +YaleConfigEntry = ConfigEntry["YaleDataUpdateCoordinator"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: YaleConfigEntry) -> bool: """Set up Yale from a config entry.""" coordinator = YaleDataUpdateCoordinator(hass, entry) @@ -21,9 +23,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryAuthFailed await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATOR: coordinator, - } + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -38,11 +38,7 @@ async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - - if await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return True - return False + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py index 7cfa6ffe4b9..2fc56a9e5dd 100644 --- a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py +++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py @@ -14,26 +14,24 @@ from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import COORDINATOR, DOMAIN, STATE_MAP, YALE_ALL_ERRORS +from . import YaleConfigEntry +from .const import DOMAIN, STATE_MAP, YALE_ALL_ERRORS from .coordinator import YaleDataUpdateCoordinator from .entity import YaleAlarmEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the alarm entry.""" - async_add_entities( - [YaleAlarmDevice(coordinator=hass.data[DOMAIN][entry.entry_id][COORDINATOR])] - ) + async_add_entities([YaleAlarmDevice(coordinator=entry.runtime_data)]) class YaleAlarmDevice(YaleAlarmEntity, AlarmControlPanelEntity): diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 67fe1d74293..a1b94b907de 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -7,12 +7,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATOR, DOMAIN +from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator from .entity import YaleAlarmEntity, YaleEntity @@ -45,13 +44,11 @@ SENSOR_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Yale binary sensor entry.""" - coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data sensors: list[YaleDoorSensor | YaleProblemSensor] = [ YaleDoorSensor(coordinator, data) for data in coordinator.data["door_windows"] ] diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 54fc905d1aa..3ce63cb3fbb 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -5,11 +5,10 @@ from __future__ import annotations from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import COORDINATOR, DOMAIN +from . import YaleConfigEntry from .coordinator import YaleDataUpdateCoordinator from .entity import YaleAlarmEntity @@ -23,14 +22,12 @@ BUTTON_TYPES = ( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the button from a config entry.""" - coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data async_add_entities( [YalePanicButton(coordinator, description) for description in BUTTON_TYPES] diff --git a/homeassistant/components/yale_smart_alarm/const.py b/homeassistant/components/yale_smart_alarm/const.py index 58126449e53..2582854a3bc 100644 --- a/homeassistant/components/yale_smart_alarm/const.py +++ b/homeassistant/components/yale_smart_alarm/const.py @@ -26,7 +26,6 @@ MANUFACTURER = "Yale" MODEL = "main" DOMAIN = "yale_smart_alarm" -COORDINATOR = "coordinator" DEFAULT_SCAN_INTERVAL = 15 diff --git a/homeassistant/components/yale_smart_alarm/diagnostics.py b/homeassistant/components/yale_smart_alarm/diagnostics.py index 99ec977de20..82d2ca9a915 100644 --- a/homeassistant/components/yale_smart_alarm/diagnostics.py +++ b/homeassistant/components/yale_smart_alarm/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import COORDINATOR, DOMAIN -from .coordinator import YaleDataUpdateCoordinator +from . import YaleConfigEntry TO_REDACT = { "address", @@ -24,12 +22,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: YaleConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data assert coordinator.yale get_all_data = await hass.async_add_executor_job(coordinator.yale.get_all) diff --git a/homeassistant/components/yale_smart_alarm/lock.py b/homeassistant/components/yale_smart_alarm/lock.py index 7a7b3aa4af4..3b4d0a19039 100644 --- a/homeassistant/components/yale_smart_alarm/lock.py +++ b/homeassistant/components/yale_smart_alarm/lock.py @@ -5,15 +5,14 @@ from __future__ import annotations from typing import TYPE_CHECKING, Any from homeassistant.components.lock import LockEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_CODE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import YaleConfigEntry from .const import ( CONF_LOCK_CODE_DIGITS, - COORDINATOR, DEFAULT_LOCK_CODE_DIGITS, DOMAIN, YALE_ALL_ERRORS, @@ -23,13 +22,11 @@ from .entity import YaleEntity async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, entry: YaleConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Yale lock entry.""" - coordinator: YaleDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ - COORDINATOR - ] + coordinator = entry.runtime_data code_format = entry.options.get(CONF_LOCK_CODE_DIGITS, DEFAULT_LOCK_CODE_DIGITS) async_add_entities( From 7fd60ddba43917d4a8fa9ed9e6f30cfab622d746 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 1 May 2024 21:19:55 +0200 Subject: [PATCH 245/272] Fix MQTT discovery cooldown too short with large setup (#116550) * Fix MQTT discovery cooldown too short with large setup * Set to 5 sec * Only change the discovery cooldown * Fire immediatly when teh debouncing period is over --- homeassistant/components/mqtt/client.py | 16 ++++++++++++---- homeassistant/components/mqtt/discovery.py | 4 ++-- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 5eb85b1679c..e96ad9318d5 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -82,7 +82,7 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) -DISCOVERY_COOLDOWN = 2 +DISCOVERY_COOLDOWN = 5 INITIAL_SUBSCRIBE_COOLDOWN = 1.0 SUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1 @@ -348,6 +348,12 @@ class EnsureJobAfterCooldown: self._task = create_eager_task(self._async_job()) self._task.add_done_callback(self._async_task_done) + async def async_fire(self) -> None: + """Execute the job immediately.""" + if self._task: + await self._task + self._async_execute() + @callback def _async_cancel_timer(self) -> None: """Cancel any pending task.""" @@ -840,7 +846,7 @@ class MQTT: for topic, qos in subscriptions.items(): _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - self._last_subscribe = time.time() + self._last_subscribe = time.monotonic() if result == 0: await self._wait_for_mid(mid) @@ -870,6 +876,8 @@ class MQTT: await self._ha_started.wait() # Wait for Home Assistant to start await self._discovery_cooldown() # Wait for MQTT discovery to cool down # Update subscribe cooldown period to a shorter time + # and make sure we flush the debouncer + await self._subscribe_debouncer.async_fire() self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN) await self.async_publish( topic=birth_message.topic, @@ -1115,7 +1123,7 @@ class MQTT: async def _discovery_cooldown(self) -> None: """Wait until all discovery and subscriptions are processed.""" - now = time.time() + now = time.monotonic() # Reset discovery and subscribe cooldowns self._mqtt_data.last_discovery = now self._last_subscribe = now @@ -1127,7 +1135,7 @@ class MQTT: ) while now < wait_until: await asyncio.sleep(wait_until - now) - now = time.time() + now = time.monotonic() last_discovery = self._mqtt_data.last_discovery last_subscribe = ( now if self._pending_subscriptions else self._last_subscribe diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index e330cd9b44b..08d86c1a1a4 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -177,7 +177,7 @@ async def async_start( # noqa: C901 @callback def async_discovery_message_received(msg: ReceiveMessage) -> None: # noqa: C901 """Process the received message.""" - mqtt_data.last_discovery = time.time() + mqtt_data.last_discovery = time.monotonic() payload = msg.payload topic = msg.topic topic_trimmed = topic.replace(f"{discovery_topic}/", "", 1) @@ -370,7 +370,7 @@ async def async_start( # noqa: C901 ) ) - mqtt_data.last_discovery = time.time() + mqtt_data.last_discovery = time.monotonic() mqtt_integrations = await async_get_mqtt(hass) for integration, topics in mqtt_integrations.items(): From a25e202ef0269ab6438930e958e55291d3ba01eb Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 May 2024 21:27:34 +0200 Subject: [PATCH 246/272] Use runtime data for FritzBox Call Monitor (#116553) --- .../fritzbox_callmonitor/__init__.py | 45 +++++++------------ .../components/fritzbox_callmonitor/const.py | 2 - .../components/fritzbox_callmonitor/sensor.py | 9 ++-- 3 files changed, 18 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py index bd6b6ab125f..061017f420c 100644 --- a/homeassistant/components/fritzbox_callmonitor/__init__.py +++ b/homeassistant/components/fritzbox_callmonitor/__init__.py @@ -11,19 +11,16 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from .base import FritzBoxPhonebook -from .const import ( - CONF_PHONEBOOK, - CONF_PREFIXES, - DOMAIN, - FRITZBOX_PHONEBOOK, - PLATFORMS, - UNDO_UPDATE_LISTENER, -) +from .const import CONF_PHONEBOOK, CONF_PREFIXES, PLATFORMS _LOGGER = logging.getLogger(__name__) +FritzBoxCallMonitorConfigEntry = ConfigEntry[FritzBoxPhonebook] -async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + +async def async_setup_entry( + hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry +) -> bool: """Set up the fritzbox_callmonitor platforms.""" fritzbox_phonebook = FritzBoxPhonebook( host=config_entry.data[CONF_HOST], @@ -51,34 +48,22 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.error("Unable to connect to AVM FRITZ!Box call monitor: %s", ex) raise ConfigEntryNotReady from ex - undo_listener = config_entry.add_update_listener(update_listener) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][config_entry.entry_id] = { - FRITZBOX_PHONEBOOK: fritzbox_phonebook, - UNDO_UPDATE_LISTENER: undo_listener, - } - + config_entry.runtime_data = fritzbox_phonebook + config_entry.async_on_unload(config_entry.add_update_listener(update_listener)) await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) return True -async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry +) -> bool: """Unloading the fritzbox_callmonitor platforms.""" - - unload_ok = await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ) - - hass.data[DOMAIN][config_entry.entry_id][UNDO_UPDATE_LISTENER]() - - if unload_ok: - hass.data[DOMAIN].pop(config_entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, config_entry: FritzBoxCallMonitorConfigEntry +) -> None: """Update listener to reload after option has changed.""" await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/fritzbox_callmonitor/const.py b/homeassistant/components/fritzbox_callmonitor/const.py index 406a1dd6d64..60618817318 100644 --- a/homeassistant/components/fritzbox_callmonitor/const.py +++ b/homeassistant/components/fritzbox_callmonitor/const.py @@ -38,5 +38,3 @@ DOMAIN: Final = "fritzbox_callmonitor" MANUFACTURER: Final = "AVM" PLATFORMS = [Platform.SENSOR] -UNDO_UPDATE_LISTENER: Final = "undo_update_listener" -FRITZBOX_PHONEBOOK: Final = "fritzbox_phonebook" diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 0a127ec36b3..9cd37411698 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -14,19 +14,18 @@ from typing import Any, cast from fritzconnection.core.fritzmonitor import FritzMonitor from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import FritzBoxCallMonitorConfigEntry from .base import FritzBoxPhonebook from .const import ( ATTR_PREFIXES, CONF_PHONEBOOK, CONF_PREFIXES, DOMAIN, - FRITZBOX_PHONEBOOK, MANUFACTURER, SERIAL_NUMBER, FritzState, @@ -48,13 +47,11 @@ class CallState(StrEnum): async def async_setup_entry( hass: HomeAssistant, - config_entry: ConfigEntry, + config_entry: FritzBoxCallMonitorConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the fritzbox_callmonitor sensor from config_entry.""" - fritzbox_phonebook: FritzBoxPhonebook = hass.data[DOMAIN][config_entry.entry_id][ - FRITZBOX_PHONEBOOK - ] + fritzbox_phonebook = config_entry.runtime_data phonebook_id: int = config_entry.data[CONF_PHONEBOOK] prefixes: list[str] | None = config_entry.options.get(CONF_PREFIXES) From ad61e5f237e80477156b3b1e82ad4c07003c3642 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 1 May 2024 21:48:31 +0200 Subject: [PATCH 247/272] Store runtime data inside the config entry in Tankerkoenig (#116532) --- .../components/tankerkoenig/__init__.py | 23 ++++++++++--------- .../components/tankerkoenig/binary_sensor.py | 10 ++++---- .../components/tankerkoenig/coordinator.py | 4 +++- .../components/tankerkoenig/diagnostics.py | 8 +++---- .../components/tankerkoenig/sensor.py | 10 ++++---- 5 files changed, 28 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index ac009b7a274..78bced05b36 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -2,20 +2,21 @@ from __future__ import annotations -from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from .const import DEFAULT_SCAN_INTERVAL, DOMAIN -from .coordinator import TankerkoenigDataUpdateCoordinator +from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: TankerkoenigConfigEntry +) -> bool: """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) @@ -27,7 +28,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_setup() await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator entry.async_on_unload(entry.add_update_listener(_async_update_listener)) @@ -36,15 +37,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: TankerkoenigConfigEntry +) -> bool: """Unload Tankerkoenig 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_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def _async_update_listener( + hass: HomeAssistant, entry: TankerkoenigConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 03ffb819a1f..774262a8854 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -10,23 +10,23 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import TankerkoenigDataUpdateCoordinator +from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TankerkoenigConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the tankerkoenig binary sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( StationOpenBinarySensorEntity( diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 458c629f422..117a58ee2db 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -28,11 +28,13 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS _LOGGER = logging.getLogger(__name__) +TankerkoenigConfigEntry = ConfigEntry["TankerkoenigDataUpdateCoordinator"] + class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): """Get the latest data from the API.""" - config_entry: ConfigEntry + config_entry: TankerkoenigConfigEntry def __init__( self, diff --git a/homeassistant/components/tankerkoenig/diagnostics.py b/homeassistant/components/tankerkoenig/diagnostics.py index 0af5b29c5a8..874a73712eb 100644 --- a/homeassistant/components/tankerkoenig/diagnostics.py +++ b/homeassistant/components/tankerkoenig/diagnostics.py @@ -6,7 +6,6 @@ from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -15,17 +14,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import TankerkoenigDataUpdateCoordinator +from .coordinator import TankerkoenigConfigEntry TO_REDACT = {CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIQUE_ID} async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: TankerkoenigConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data return { "entry": async_redact_data(entry.as_dict(), TO_REDACT), diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 33476e75262..776ea669d5b 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -7,7 +7,6 @@ import logging from aiotankerkoenig import GasType, PriceInfo, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -21,19 +20,20 @@ from .const import ( ATTR_STATION_NAME, ATTR_STREET, ATTRIBUTION, - DOMAIN, ) -from .coordinator import TankerkoenigDataUpdateCoordinator +from .coordinator import TankerkoenigConfigEntry, TankerkoenigDataUpdateCoordinator from .entity import TankerkoenigCoordinatorEntity _LOGGER = logging.getLogger(__name__) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TankerkoenigConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the tankerkoenig sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [] for station in coordinator.stations.values(): From a4139f1a61f0ba58fe96bc16bdda3836614f2576 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 1 May 2024 21:51:47 +0200 Subject: [PATCH 248/272] Store runtime data inside the config entry in Proximity (#116533) --- .../components/proximity/__init__.py | 20 +++++++------------ .../components/proximity/coordinator.py | 4 +++- .../components/proximity/diagnostics.py | 8 +++----- homeassistant/components/proximity/sensor.py | 9 +++++---- 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index d739efe39e7..813686789a2 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -38,7 +38,7 @@ from .const import ( DOMAIN, UNITS, ) -from .coordinator import ProximityDataUpdateCoordinator +from .coordinator import ProximityConfigEntry, ProximityDataUpdateCoordinator from .helpers import entity_used_in _LOGGER = logging.getLogger(__name__) @@ -65,7 +65,9 @@ CONFIG_SCHEMA = vol.Schema( async def _async_setup_legacy( - hass: HomeAssistant, entry: ConfigEntry, coordinator: ProximityDataUpdateCoordinator + hass: HomeAssistant, + entry: ProximityConfigEntry, + coordinator: ProximityDataUpdateCoordinator, ) -> None: """Legacy proximity entity handling, can be removed in 2024.8.""" friendly_name = entry.data[CONF_NAME] @@ -133,12 +135,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ProximityConfigEntry) -> bool: """Set up Proximity from a config entry.""" _LOGGER.debug("setup %s with config:%s", entry.title, entry.data) - hass.data.setdefault(DOMAIN, {}) - coordinator = ProximityDataUpdateCoordinator(hass, entry.title, dict(entry.data)) entry.async_on_unload( @@ -158,7 +158,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator if entry.source == SOURCE_IMPORT: await _async_setup_legacy(hass, entry, coordinator) @@ -170,13 +170,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms( - entry, [Platform.SENSOR] - ) - if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, [Platform.SENSOR]) async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/proximity/coordinator.py b/homeassistant/components/proximity/coordinator.py index ff7eedb5cd0..2fd463aa1b7 100644 --- a/homeassistant/components/proximity/coordinator.py +++ b/homeassistant/components/proximity/coordinator.py @@ -45,6 +45,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) +ProximityConfigEntry = ConfigEntry["ProximityDataUpdateCoordinator"] + @dataclass class StateChangedData: @@ -73,7 +75,7 @@ DEFAULT_PROXIMITY_DATA: dict[str, str | int | None] = { class ProximityDataUpdateCoordinator(DataUpdateCoordinator[ProximityData]): """Proximity data update coordinator.""" - config_entry: ConfigEntry + config_entry: ProximityConfigEntry def __init__( self, hass: HomeAssistant, friendly_name: str, config: ConfigType diff --git a/homeassistant/components/proximity/diagnostics.py b/homeassistant/components/proximity/diagnostics.py index d296c489e94..805cbc192f9 100644 --- a/homeassistant/components/proximity/diagnostics.py +++ b/homeassistant/components/proximity/diagnostics.py @@ -8,7 +8,6 @@ from homeassistant.components.device_tracker import ATTR_GPS, ATTR_IP, ATTR_MAC from homeassistant.components.diagnostics import REDACTED, async_redact_data from homeassistant.components.person import ATTR_USER_ID from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -19,8 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import ProximityDataUpdateCoordinator +from .coordinator import ProximityConfigEntry TO_REDACT = { ATTR_GPS, @@ -35,10 +33,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: ProximityConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data diag_data = { "entry": entry.as_dict(), diff --git a/homeassistant/components/proximity/sensor.py b/homeassistant/components/proximity/sensor.py index 8eb7aae9bb9..55d4ca02b9b 100644 --- a/homeassistant/components/proximity/sensor.py +++ b/homeassistant/components/proximity/sensor.py @@ -9,7 +9,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfLength from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -25,7 +24,7 @@ from .const import ( ATTR_NEAREST_DIST_TO, DOMAIN, ) -from .coordinator import ProximityDataUpdateCoordinator +from .coordinator import ProximityConfigEntry, ProximityDataUpdateCoordinator DIRECTIONS = ["arrived", "away_from", "stationary", "towards"] @@ -81,11 +80,13 @@ def _device_info(coordinator: ProximityDataUpdateCoordinator) -> DeviceInfo: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ProximityConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the proximity sensors.""" - coordinator: ProximityDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[ProximitySensor | ProximityTrackedEntitySensor] = [ ProximitySensor(description, coordinator) From 054fb5af31b4cd574bea0fda155a2f5915c37b02 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 1 May 2024 21:54:05 +0200 Subject: [PATCH 249/272] Store runtime data inside the config entry in PegelOnline (#116534) --- homeassistant/components/pegel_online/__init__.py | 13 ++++++------- homeassistant/components/pegel_online/sensor.py | 9 +++++---- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/pegel_online/__init__.py b/homeassistant/components/pegel_online/__init__.py index 38b952293e0..90f25b00518 100644 --- a/homeassistant/components/pegel_online/__init__.py +++ b/homeassistant/components/pegel_online/__init__.py @@ -11,15 +11,17 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_STATION, DOMAIN +from .const import CONF_STATION from .coordinator import PegelOnlineDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] +PegelOnlineConfigEntry = ConfigEntry[PegelOnlineDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: PegelOnlineConfigEntry) -> bool: """Set up PEGELONLINE entry.""" station_uuid = entry.data[CONF_STATION] @@ -32,8 +34,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -42,6 +43,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload PEGELONLINE entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/pegel_online/sensor.py b/homeassistant/components/pegel_online/sensor.py index 6471b8cbd4b..50eb80bafa8 100644 --- a/homeassistant/components/pegel_online/sensor.py +++ b/homeassistant/components/pegel_online/sensor.py @@ -12,12 +12,11 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import PegelOnlineConfigEntry from .coordinator import PegelOnlineDataUpdateCoordinator from .entity import PegelOnlineEntity @@ -92,10 +91,12 @@ SENSORS: tuple[PegelOnlineSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: PegelOnlineConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the PEGELONLINE sensor.""" - coordinator: PegelOnlineDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ From 6cb703aa1d556fdd342df18db23637062c2547c3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 21:58:21 +0200 Subject: [PATCH 250/272] Use config entry runtime data in Trafikverket Weather (#116554) --- .../trafikverket_weatherstation/__init__.py | 12 ++++++------ .../trafikverket_weatherstation/coordinator.py | 13 +++++++++---- .../trafikverket_weatherstation/sensor.py | 8 +++++--- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py index e1cd9c90909..1bd7fc69ae4 100644 --- a/homeassistant/components/trafikverket_weatherstation/__init__.py +++ b/homeassistant/components/trafikverket_weatherstation/__init__.py @@ -5,17 +5,18 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS from .coordinator import TVDataUpdateCoordinator +TVWeatherConfigEntry = ConfigEntry[TVDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TVWeatherConfigEntry) -> bool: """Set up Trafikverket Weatherstation from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass, entry) + coordinator = TVDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,5 +24,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Trafikverket Weatherstation config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 508ae7eec16..e0319b1b932 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import TYPE_CHECKING from pytrafikverket.exceptions import ( InvalidAuthentication, @@ -12,7 +13,6 @@ from pytrafikverket.exceptions import ( ) from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -21,6 +21,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_STATION, DOMAIN +if TYPE_CHECKING: + from . import TVWeatherConfigEntry + _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=10) @@ -28,7 +31,9 @@ TIME_BETWEEN_UPDATES = timedelta(minutes=10) class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]): """A Sensibo Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: TVWeatherConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Sensibo coordinator.""" super().__init__( hass, @@ -37,9 +42,9 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]): update_interval=TIME_BETWEEN_UPDATES, ) self._weather_api = TrafikverketWeather( - async_get_clientsession(hass), entry.data[CONF_API_KEY] + async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] ) - self._station = entry.data[CONF_STATION] + self._station = self.config_entry.data[CONF_STATION] async def _async_update_data(self) -> WeatherStationInfo: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index bd15c34ff01..4bd14448546 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -14,7 +14,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( DEGREE, PERCENTAGE, @@ -30,6 +29,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import TVWeatherConfigEntry from .const import ATTRIBUTION, CONF_STATION, DOMAIN from .coordinator import TVDataUpdateCoordinator @@ -200,11 +200,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TVWeatherConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TrafikverketWeatherStation( From b2c1cd3e2aa8c8c14a7630db193cd22abec90861 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 21:59:07 +0200 Subject: [PATCH 251/272] Use config entry runtime data in Trafikverket Camera (#116552) --- .../components/trafikverket_camera/__init__.py | 14 ++++++-------- .../trafikverket_camera/binary_sensor.py | 11 ++++++----- .../components/trafikverket_camera/camera.py | 8 ++++---- .../components/trafikverket_camera/coordinator.py | 15 +++++++++++---- .../components/trafikverket_camera/sensor.py | 11 ++++++----- 5 files changed, 33 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index 998b667add3..3186e803087 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -19,13 +19,15 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) _LOGGER = logging.getLogger(__name__) +TVCameraConfigEntry = ConfigEntry[TVDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TVCameraConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass, entry) + coordinator = TVDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -34,11 +36,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Trafikverket Camera config entry.""" - - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index 56af099d54b..b367fa0fb45 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -9,12 +9,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN -from .coordinator import CameraData, TVDataUpdateCoordinator +from . import TVCameraConfigEntry +from .coordinator import CameraData from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -35,11 +34,13 @@ BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TVCameraConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Trafikverket Camera binary sensor platform.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ TrafikverketCameraBinarySensor( diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index 0fa70a886b2..1ae48732c88 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -6,24 +6,24 @@ from collections.abc import Mapping from typing import Any from homeassistant.components.camera import Camera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN +from . import TVCameraConfigEntry +from .const import ATTR_DESCRIPTION, ATTR_TYPE from .coordinator import TVDataUpdateCoordinator from .entity import TrafikverketCameraEntity async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: TVCameraConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up a Trafikverket Camera.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ diff --git a/homeassistant/components/trafikverket_camera/coordinator.py b/homeassistant/components/trafikverket_camera/coordinator.py index 03b70009189..cceea9afc5c 100644 --- a/homeassistant/components/trafikverket_camera/coordinator.py +++ b/homeassistant/components/trafikverket_camera/coordinator.py @@ -6,6 +6,7 @@ from dataclasses import dataclass from datetime import timedelta from io import BytesIO import logging +from typing import TYPE_CHECKING from pytrafikverket.exceptions import ( InvalidAuthentication, @@ -15,7 +16,6 @@ from pytrafikverket.exceptions import ( ) from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -24,6 +24,9 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DOMAIN +if TYPE_CHECKING: + from . import TVCameraConfigEntry + _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -39,7 +42,9 @@ class CameraData: class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): """A Trafikverket Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: TVCameraConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( hass, @@ -48,8 +53,10 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[CameraData]): update_interval=TIME_BETWEEN_UPDATES, ) self.session = async_get_clientsession(hass) - self._camera_api = TrafikverketCamera(self.session, entry.data[CONF_API_KEY]) - self._id = entry.data[CONF_ID] + self._camera_api = TrafikverketCamera( + self.session, self.config_entry.data[CONF_API_KEY] + ) + self._id = self.config_entry.data[CONF_ID] async def _async_update_data(self) -> CameraData: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index f41eb1fa2a2..cb5c458f742 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -11,14 +11,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN -from .coordinator import CameraData, TVDataUpdateCoordinator +from . import TVCameraConfigEntry +from .coordinator import CameraData from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -73,11 +72,13 @@ SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TVCameraConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Trafikverket Camera sensor platform.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( TrafikverketCameraSensor(coordinator, entry.entry_id, description) for description in SENSOR_TYPES From 5e9a864f5b6271abd7c2a7f78daa0d6f4b2e85cf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 22:02:36 +0200 Subject: [PATCH 252/272] Use config entry runtime data in Sensibo (#116530) * Use config entry runtime data in Sensibo * Add typing * Fixes coordinator * Move import --- homeassistant/components/sensibo/__init__.py | 14 ++++++------- .../components/sensibo/binary_sensor.py | 9 +++++---- homeassistant/components/sensibo/button.py | 9 +++++---- homeassistant/components/sensibo/climate.py | 8 +++++--- .../components/sensibo/coordinator.py | 20 +++++++++++-------- .../components/sensibo/diagnostics.py | 8 +++----- homeassistant/components/sensibo/number.py | 9 +++++---- homeassistant/components/sensibo/select.py | 8 +++++--- homeassistant/components/sensibo/sensor.py | 9 +++++---- homeassistant/components/sensibo/switch.py | 8 +++++--- homeassistant/components/sensibo/update.py | 9 +++++---- 11 files changed, 61 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/sensibo/__init__.py b/homeassistant/components/sensibo/__init__.py index b14d06c5811..5a7e09f539e 100644 --- a/homeassistant/components/sensibo/__init__.py +++ b/homeassistant/components/sensibo/__init__.py @@ -15,13 +15,15 @@ from .const import DOMAIN, LOGGER, PLATFORMS from .coordinator import SensiboDataUpdateCoordinator from .util import NoDevicesError, NoUsernameError, async_validate_api +SensiboConfigEntry = ConfigEntry["SensiboDataUpdateCoordinator"] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: SensiboConfigEntry) -> bool: """Set up Sensibo from a config entry.""" - coordinator = SensiboDataUpdateCoordinator(hass, entry) + coordinator = SensiboDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -30,11 +32,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Sensibo config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - del hass.data[DOMAIN][entry.entry_id] - if not hass.data[DOMAIN]: - del hass.data[DOMAIN] - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index a34c7884ac7..6d1acd99166 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -13,12 +13,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -115,11 +114,13 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SENSOR_TYPES} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo binary sensor platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py index fbfabaa97fb..9ac504537fa 100644 --- a/homeassistant/components/sensibo/button.py +++ b/homeassistant/components/sensibo/button.py @@ -6,12 +6,11 @@ from dataclasses import dataclass from typing import Any from homeassistant.components.button import ButtonEntity, ButtonEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -34,11 +33,13 @@ DEVICE_BUTTON_TYPES = SensiboButtonEntityDescription( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo binary sensor platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index f7661a3ee80..390ebc080b8 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -14,7 +14,6 @@ from homeassistant.components.climate import ( ClimateEntityFeature, HVACMode, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, ATTR_STATE, @@ -28,6 +27,7 @@ from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.unit_conversion import TemperatureConverter +from . import SensiboConfigEntry from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -117,11 +117,13 @@ def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int: async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Sensibo climate entry.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities = [ SensiboClimate(coordinator, device_id) diff --git a/homeassistant/components/sensibo/coordinator.py b/homeassistant/components/sensibo/coordinator.py index 4f4f76aba10..d654a7cb072 100644 --- a/homeassistant/components/sensibo/coordinator.py +++ b/homeassistant/components/sensibo/coordinator.py @@ -3,12 +3,12 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING from pysensibo import SensiboClient from pysensibo.exceptions import AuthenticationError, SensiboError from pysensibo.model import SensiboData -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -18,19 +18,19 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER, TIMEOUT +if TYPE_CHECKING: + from . import SensiboConfigEntry + REQUEST_REFRESH_DELAY = 0.35 class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): """A Sensibo Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: SensiboConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Sensibo coordinator.""" - self.client = SensiboClient( - entry.data[CONF_API_KEY], - session=async_get_clientsession(hass), - timeout=TIMEOUT, - ) super().__init__( hass, LOGGER, @@ -42,10 +42,14 @@ class SensiboDataUpdateCoordinator(DataUpdateCoordinator[SensiboData]): hass, LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False ), ) + self.client = SensiboClient( + self.config_entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + timeout=TIMEOUT, + ) async def _async_update_data(self) -> SensiboData: """Fetch data from Sensibo.""" - try: data = await self.client.async_get_devices_data() except AuthenticationError as error: diff --git a/homeassistant/components/sensibo/diagnostics.py b/homeassistant/components/sensibo/diagnostics.py index d00da7e1223..e08ad9f8b53 100644 --- a/homeassistant/components/sensibo/diagnostics.py +++ b/homeassistant/components/sensibo/diagnostics.py @@ -5,11 +5,9 @@ from __future__ import annotations from typing import Any from homeassistant.components.diagnostics.util import async_redact_data -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN -from .coordinator import SensiboDataUpdateCoordinator +from . import SensiboConfigEntry TO_REDACT = { "location", @@ -31,10 +29,10 @@ TO_REDACT = { async def async_get_config_entry_diagnostics( - hass: HomeAssistant, entry: ConfigEntry + hass: HomeAssistant, entry: SensiboConfigEntry ) -> dict[str, Any]: """Return diagnostics for Sensibo config entry.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data diag_data = {} diag_data["raw"] = async_redact_data(coordinator.data.raw, TO_REDACT) for device, device_data in coordinator.data.parsed.items(): diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 9c7b97ff79f..baa056f0eea 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -13,12 +13,11 @@ from homeassistant.components.number import ( NumberEntity, NumberEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -64,11 +63,13 @@ DEVICE_NUMBER_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo number platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensiboNumber(coordinator, device_id, description) diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 798d4735b16..cd0499aabc0 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -9,11 +9,11 @@ from typing import TYPE_CHECKING, Any from pysensibo.model import SensiboDevice from homeassistant.components.select import SelectEntity, SelectEntityDescription -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SensiboConfigEntry from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -52,11 +52,13 @@ DEVICE_SELECT_TYPES = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo number platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensiboSelect(coordinator, device_id, description) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 81ab3a06067..16adfd5afe3 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -15,7 +15,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, @@ -30,7 +29,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import DOMAIN +from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity @@ -231,11 +230,13 @@ DESCRIPTION_BY_MODELS = { async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo sensor platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index a8ebd63fa43..46906ac1871 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -13,11 +13,11 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import SensiboConfigEntry from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, async_handle_api_call @@ -76,11 +76,13 @@ DESCRIPTION_BY_MODELS = {"pure": PURE_SWITCH_TYPES} async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo Switch platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensiboDeviceSwitch(coordinator, device_id, description) diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 9376cd1eb38..d52565564a6 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -12,12 +12,11 @@ from homeassistant.components.update import ( UpdateEntity, UpdateEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from . import SensiboConfigEntry from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity @@ -44,11 +43,13 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceUpdateEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: SensiboConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up Sensibo Update platform.""" - coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( SensiboDeviceUpdate(coordinator, device_id, description) From b089f89f14f381bdf7b45880071e68b4a35f782b Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 22:05:47 +0200 Subject: [PATCH 253/272] Use config entry runtime data in Trafikverket Ferry (#116557) --- .../components/trafikverket_ferry/__init__.py | 12 +++++------ .../trafikverket_ferry/coordinator.py | 20 +++++++++++-------- .../components/trafikverket_ferry/sensor.py | 8 +++++--- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/trafikverket_ferry/__init__.py b/homeassistant/components/trafikverket_ferry/__init__.py index 8c8c121881f..dbcbc1a4aba 100644 --- a/homeassistant/components/trafikverket_ferry/__init__.py +++ b/homeassistant/components/trafikverket_ferry/__init__.py @@ -5,17 +5,18 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, PLATFORMS +from .const import PLATFORMS from .coordinator import TVDataUpdateCoordinator +TVFerryConfigEntry = ConfigEntry[TVDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TVFerryConfigEntry) -> bool: """Set up Trafikverket Ferry from a config entry.""" - coordinator = TVDataUpdateCoordinator(hass, entry) + coordinator = TVDataUpdateCoordinator(hass) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - + entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -23,5 +24,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Trafikverket Ferry config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 8d0492b1e43..cb11889345a 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -4,13 +4,12 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pytrafikverket import TrafikverketFerry from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -20,6 +19,9 @@ from homeassistant.util import dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN +if TYPE_CHECKING: + from . import TVFerryConfigEntry + _LOGGER = logging.getLogger(__name__) TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -48,7 +50,9 @@ def next_departuredate(departure: list[str]) -> date: class TVDataUpdateCoordinator(DataUpdateCoordinator): """A Trafikverket Data Update Coordinator.""" - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + config_entry: TVFerryConfigEntry + + def __init__(self, hass: HomeAssistant) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( hass, @@ -57,12 +61,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): update_interval=TIME_BETWEEN_UPDATES, ) self._ferry_api = TrafikverketFerry( - async_get_clientsession(hass), entry.data[CONF_API_KEY] + async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] ) - self._from: str = entry.data[CONF_FROM] - self._to: str = entry.data[CONF_TO] - self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) - self._weekdays: list[str] = entry.data[CONF_WEEKDAY] + self._from: str = self.config_entry.data[CONF_FROM] + self._to: str = self.config_entry.data[CONF_TO] + self._time: time | None = dt_util.parse_time(self.config_entry.data[CONF_TIME]) + self._weekdays: list[str] = self.config_entry.data[CONF_WEEKDAY] async def _async_update_data(self) -> dict[str, Any]: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index 93f2d1987b6..5a13159ecfd 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -21,6 +20,7 @@ from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util.dt import as_utc +from . import TVFerryConfigEntry from .const import ATTRIBUTION, DOMAIN from .coordinator import TVDataUpdateCoordinator @@ -88,11 +88,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TVFerryConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ From 3bf67f3dddce45f747bf3d784f6111066b978f91 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 1 May 2024 22:08:19 +0200 Subject: [PATCH 254/272] Use config entry runtime data in Trafikverket Train (#116556) --- .../components/trafikverket_train/__init__.py | 12 +++++------ .../trafikverket_train/coordinator.py | 21 ++++++++++++------- .../components/trafikverket_train/sensor.py | 8 ++++--- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 8b427c3431d..4bf1f681807 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -16,11 +16,13 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_FILTER_PRODUCT, CONF_FROM, CONF_TO, DOMAIN, PLATFORMS +from .const import CONF_FROM, CONF_TO, PLATFORMS from .coordinator import TVDataUpdateCoordinator +TVTrainConfigEntry = ConfigEntry[TVDataUpdateCoordinator] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + +async def async_setup_entry(hass: HomeAssistant, entry: TVTrainConfigEntry) -> bool: """Set up Trafikverket Train from a config entry.""" http_session = async_get_clientsession(hass) @@ -37,11 +39,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f" {entry.data[CONF_TO]}. Error: {error} " ) from error - coordinator = TVDataUpdateCoordinator( - hass, entry, to_station, from_station, entry.options.get(CONF_FILTER_PRODUCT) - ) + coordinator = TVDataUpdateCoordinator(hass, to_station, from_station) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + entry.runtime_data = coordinator entity_reg = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_reg, entry.entry_id) diff --git a/homeassistant/components/trafikverket_train/coordinator.py b/homeassistant/components/trafikverket_train/coordinator.py index cf78228ed58..e56f5d3a2e9 100644 --- a/homeassistant/components/trafikverket_train/coordinator.py +++ b/homeassistant/components/trafikverket_train/coordinator.py @@ -5,6 +5,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, time, timedelta import logging +from typing import TYPE_CHECKING from pytrafikverket import TrafikverketTrain from pytrafikverket.exceptions import ( @@ -14,7 +15,6 @@ from pytrafikverket.exceptions import ( ) from pytrafikverket.trafikverket_train import StationInfo, TrainStop -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed @@ -22,9 +22,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_TIME, DOMAIN +from .const import CONF_FILTER_PRODUCT, CONF_TIME, DOMAIN from .util import next_departuredate +if TYPE_CHECKING: + from . import TVTrainConfigEntry + @dataclass class TrainData: @@ -65,13 +68,13 @@ def _get_as_joined(information: list[str] | None) -> str | None: class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): """A Trafikverket Data Update Coordinator.""" + config_entry: TVTrainConfigEntry + def __init__( self, hass: HomeAssistant, - entry: ConfigEntry, to_station: StationInfo, from_station: StationInfo, - filter_product: str | None, ) -> None: """Initialize the Trafikverket coordinator.""" super().__init__( @@ -81,13 +84,15 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[TrainData]): update_interval=TIME_BETWEEN_UPDATES, ) self._train_api = TrafikverketTrain( - async_get_clientsession(hass), entry.data[CONF_API_KEY] + async_get_clientsession(hass), self.config_entry.data[CONF_API_KEY] ) self.from_station: StationInfo = from_station self.to_station: StationInfo = to_station - self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) - self._weekdays: list[str] = entry.data[CONF_WEEKDAY] - self._filter_product = filter_product + self._time: time | None = dt_util.parse_time(self.config_entry.data[CONF_TIME]) + self._weekdays: list[str] = self.config_entry.data[CONF_WEEKDAY] + self._filter_product: str | None = self.config_entry.options.get( + CONF_FILTER_PRODUCT + ) async def _async_update_data(self) -> TrainData: """Fetch data from Trafikverket.""" diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 22d8aba4725..e5331a47d16 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -12,7 +12,6 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, UnitOfTime from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -20,6 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import TVTrainConfigEntry from .const import ATTRIBUTION, DOMAIN from .coordinator import TrainData, TVDataUpdateCoordinator @@ -106,11 +106,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: TVTrainConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Trafikverket sensor entry.""" - coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + coordinator = entry.runtime_data async_add_entities( [ From e68901235b411189e8309f17329264872afbbae7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 1 May 2024 22:13:38 +0200 Subject: [PATCH 255/272] Store runtime data in entry in Analytics Insights (#116441) --- .../components/analytics_insights/__init__.py | 22 +++++++++++-------- .../analytics_insights/coordinator.py | 7 ++++-- .../components/analytics_insights/sensor.py | 7 +++--- .../components/analytics_insights/conftest.py | 2 +- 4 files changed, 22 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/analytics_insights/__init__.py b/homeassistant/components/analytics_insights/__init__.py index 79556fb68c2..3069e8dd12d 100644 --- a/homeassistant/components/analytics_insights/__init__.py +++ b/homeassistant/components/analytics_insights/__init__.py @@ -15,10 +15,11 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import CONF_TRACKED_INTEGRATIONS, DOMAIN +from .const import CONF_TRACKED_INTEGRATIONS from .coordinator import HomeassistantAnalyticsDataUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +AnalyticsInsightsConfigEntry = ConfigEntry["AnalyticsInsightsData"] @dataclass(frozen=True) @@ -29,7 +30,9 @@ class AnalyticsInsightsData: names: dict[str, str] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( + hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry +) -> bool: """Set up Homeassistant Analytics from a config entry.""" client = HomeassistantAnalyticsClient(session=async_get_clientsession(hass)) @@ -49,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN] = AnalyticsInsightsData(coordinator=coordinator, names=names) + entry.runtime_data = AnalyticsInsightsData(coordinator=coordinator, names=names) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) @@ -57,14 +60,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_unload_entry( + hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry +) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data.pop(DOMAIN) - - return unload_ok + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) -async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: +async def update_listener( + hass: HomeAssistant, entry: AnalyticsInsightsConfigEntry +) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/analytics_insights/coordinator.py b/homeassistant/components/analytics_insights/coordinator.py index 759ce567898..2f863bf7771 100644 --- a/homeassistant/components/analytics_insights/coordinator.py +++ b/homeassistant/components/analytics_insights/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta +from typing import TYPE_CHECKING from python_homeassistant_analytics import ( CustomIntegration, @@ -12,7 +13,6 @@ from python_homeassistant_analytics import ( HomeassistantAnalyticsNotModifiedError, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -23,6 +23,9 @@ from .const import ( LOGGER, ) +if TYPE_CHECKING: + from . import AnalyticsInsightsConfigEntry + @dataclass(frozen=True) class AnalyticsData: @@ -35,7 +38,7 @@ class AnalyticsData: class HomeassistantAnalyticsDataUpdateCoordinator(DataUpdateCoordinator[AnalyticsData]): """A Homeassistant Analytics Data Update Coordinator.""" - config_entry: ConfigEntry + config_entry: AnalyticsInsightsConfigEntry def __init__( self, hass: HomeAssistant, client: HomeassistantAnalyticsClient diff --git a/homeassistant/components/analytics_insights/sensor.py b/homeassistant/components/analytics_insights/sensor.py index ee1496eb52c..f7a77743b94 100644 --- a/homeassistant/components/analytics_insights/sensor.py +++ b/homeassistant/components/analytics_insights/sensor.py @@ -10,7 +10,6 @@ from homeassistant.components.sensor import ( SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -18,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AnalyticsInsightsData +from . import AnalyticsInsightsConfigEntry from .const import DOMAIN from .coordinator import AnalyticsData, HomeassistantAnalyticsDataUpdateCoordinator @@ -60,12 +59,12 @@ def get_custom_integration_entity_description( async def async_setup_entry( hass: HomeAssistant, - entry: ConfigEntry, + entry: AnalyticsInsightsConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Initialize the entries.""" - analytics_data: AnalyticsInsightsData = hass.data[DOMAIN] + analytics_data = entry.runtime_data coordinator: HomeassistantAnalyticsDataUpdateCoordinator = ( analytics_data.coordinator ) diff --git a/tests/components/analytics_insights/conftest.py b/tests/components/analytics_insights/conftest.py index 03bd24faeea..51d25f0a2cc 100644 --- a/tests/components/analytics_insights/conftest.py +++ b/tests/components/analytics_insights/conftest.py @@ -7,10 +7,10 @@ import pytest from python_homeassistant_analytics import CurrentAnalytics from python_homeassistant_analytics.models import CustomIntegration, Integration -from homeassistant.components.analytics_insights import DOMAIN from homeassistant.components.analytics_insights.const import ( CONF_TRACKED_CUSTOM_INTEGRATIONS, CONF_TRACKED_INTEGRATIONS, + DOMAIN, ) from tests.common import MockConfigEntry, load_fixture, load_json_object_fixture From 041456759fb20f88923d593312db7d48d54454dd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 15:18:44 -0500 Subject: [PATCH 256/272] Remove duplicate mid handling in MQTT (#116531) --- homeassistant/components/mqtt/client.py | 14 +++++----- tests/components/mqtt/test_init.py | 34 +++++++++++++++++++------ 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index e96ad9318d5..88f9598596b 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -617,7 +617,7 @@ class MQTT: qos, ) _raise_on_error(msg_info.rc) - await self._wait_for_mid(msg_info.mid) + await self._async_wait_for_mid(msg_info.mid) async def async_connect(self, client_available: asyncio.Future[bool]) -> None: """Connect to the host. Does not process messages yet.""" @@ -849,7 +849,7 @@ class MQTT: self._last_subscribe = time.monotonic() if result == 0: - await self._wait_for_mid(mid) + await self._async_wait_for_mid(mid) else: _raise_on_error(result) @@ -866,7 +866,7 @@ class MQTT: for topic in topics: _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) - await self._wait_for_mid(mid) + await self._async_wait_for_mid(mid) async def _async_resubscribe_and_publish_birth_message( self, birth_message: PublishMessage @@ -1055,8 +1055,8 @@ class MQTT: # see https://github.com/eclipse/paho.mqtt.python/issues/687 # properties and reason codes are not used in Home Assistant future = self._async_get_mid_future(mid) - if future.done(): - _LOGGER.warning("Received duplicate mid: %s", mid) + if future.done() and future.exception(): + # Timed out return future.set_result(None) @@ -1104,9 +1104,9 @@ class MQTT: if not future.done(): future.set_exception(asyncio.TimeoutError) - async def _wait_for_mid(self, mid: int) -> None: + async def _async_wait_for_mid(self, mid: int) -> None: """Wait for ACK from broker.""" - # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid + # Create the mid event if not created, either _mqtt_handle_mid or _async_wait_for_mid # may be executed first. future = self._async_get_mid_future(mid) loop = self.hass.loop diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 94a8c4831b4..6cfb37df29b 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -2074,16 +2074,34 @@ async def test_handle_mqtt_on_callback( ) -> None: """Test receiving an ACK callback before waiting for it.""" await mqtt_mock_entry() - # Simulate an ACK for mid == 1, this will call mqtt_mock._mqtt_handle_mid(mid) - mqtt_client_mock.on_publish(mqtt_client_mock, None, 1) - await hass.async_block_till_done() - # Make sure the ACK has been received - await hass.async_block_till_done() - # Now call publish without call back, this will call _wait_for_mid(msg_info.mid) - await mqtt.async_publish(hass, "no_callback/test-topic", "test-payload") - # Since the mid event was already set, we should not see any timeout warning in the log + with patch.object(mqtt_client_mock, "get_mid", return_value=100): + # Simulate an ACK for mid == 100, this will call mqtt_mock._async_get_mid_future(mid) + mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) + await hass.async_block_till_done() + # Make sure the ACK has been received + await hass.async_block_till_done() + # Now call publish without call back, this will call _async_async_wait_for_mid(msg_info.mid) + await mqtt.async_publish(hass, "no_callback/test-topic", "test-payload") + # Since the mid event was already set, we should not see any timeout warning in the log + await hass.async_block_till_done() + assert "No ACK from MQTT server" not in caplog.text + + +async def test_handle_mqtt_on_callback_after_timeout( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, +) -> None: + """Test receiving an ACK after a timeout.""" + mqtt_mock = await mqtt_mock_entry() + # Simulate the mid future getting a timeout + mqtt_mock()._async_get_mid_future(100).set_exception(asyncio.TimeoutError) + # Simulate an ACK for mid == 100, being received after the timeout + mqtt_client_mock.on_publish(mqtt_client_mock, None, 100) await hass.async_block_till_done() assert "No ACK from MQTT server" not in caplog.text + assert "InvalidStateError" not in caplog.text async def test_publish_error( From fa920fd910c0db030413cfd09aa35baa70bee73b Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 1 May 2024 16:32:56 -0400 Subject: [PATCH 257/272] Bump upb_lib to 0.5.6 (#116558) --- homeassistant/components/upb/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/upb/manifest.json b/homeassistant/components/upb/manifest.json index 240660ac89f..a5e32dd298e 100644 --- a/homeassistant/components/upb/manifest.json +++ b/homeassistant/components/upb/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/upb", "iot_class": "local_push", "loggers": ["upb_lib"], - "requirements": ["upb-lib==0.5.4"] + "requirements": ["upb-lib==0.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6a2635596b..3ea8409a72b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2782,7 +2782,7 @@ unifiled==0.11 universal-silabs-flasher==0.0.18 # homeassistant.components.upb -upb-lib==0.5.4 +upb-lib==0.5.6 # homeassistant.components.upcloud upcloud-api==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index efe5be7cdfc..7e3ab79efa3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2150,7 +2150,7 @@ unifi-discovery==1.1.8 universal-silabs-flasher==0.0.18 # homeassistant.components.upb -upb-lib==0.5.4 +upb-lib==0.5.6 # homeassistant.components.upcloud upcloud-api==2.0.0 From bd24ce8d4dcc69f730e02491c1dc886abf046728 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 May 2024 22:33:07 +0200 Subject: [PATCH 258/272] Improve tankerkoenig generic coordinator typing (#116560) --- homeassistant/components/tankerkoenig/coordinator.py | 2 +- homeassistant/components/tankerkoenig/sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tankerkoenig/coordinator.py b/homeassistant/components/tankerkoenig/coordinator.py index 117a58ee2db..4ce9fce7935 100644 --- a/homeassistant/components/tankerkoenig/coordinator.py +++ b/homeassistant/components/tankerkoenig/coordinator.py @@ -31,7 +31,7 @@ _LOGGER = logging.getLogger(__name__) TankerkoenigConfigEntry = ConfigEntry["TankerkoenigDataUpdateCoordinator"] -class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): +class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator[dict[str, PriceInfo]]): """Get the latest data from the API.""" config_entry: TankerkoenigConfigEntry diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 776ea669d5b..5970f3d3b24 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging -from aiotankerkoenig import GasType, PriceInfo, Station +from aiotankerkoenig import GasType, Station from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO @@ -109,5 +109,5 @@ class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): @property def native_value(self) -> float: """Return the current price for the fuel type.""" - info: PriceInfo = self.coordinator.data[self._station_id] + info = self.coordinator.data[self._station_id] return getattr(info, self._fuel_type) From 8bc214b1852b697f6b1bfd6d7bdb1f1aef93e6de Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 May 2024 22:41:11 +0200 Subject: [PATCH 259/272] Improve airly generic coordinator typing (#116561) --- homeassistant/components/airly/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py index 6db50950ba1..fa826ba6efc 100644 --- a/homeassistant/components/airly/coordinator.py +++ b/homeassistant/components/airly/coordinator.py @@ -55,7 +55,7 @@ def set_update_interval(instances_count: int, requests_remaining: int) -> timede return interval -class AirlyDataUpdateCoordinator(DataUpdateCoordinator): +class AirlyDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str | float | int]]): """Define an object to hold Airly data.""" def __init__( From aa1af37d1b463acc677c0e20f0ff77ae698f0ff7 Mon Sep 17 00:00:00 2001 From: GraceGRD <123941606+GraceGRD@users.noreply.github.com> Date: Wed, 1 May 2024 23:13:09 +0200 Subject: [PATCH 260/272] Bump opentherm_gw to 2.2.0 (#116527) --- homeassistant/components/opentherm_gw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json index 50e0eab2643..b6ebef6e83c 100644 --- a/homeassistant/components/opentherm_gw/manifest.json +++ b/homeassistant/components/opentherm_gw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/opentherm_gw", "iot_class": "local_push", "loggers": ["pyotgw"], - "requirements": ["pyotgw==2.1.3"] + "requirements": ["pyotgw==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3ea8409a72b..9dade9b7c4b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2034,7 +2034,7 @@ pyoppleio-legacy==1.0.8 pyosoenergyapi==1.1.3 # homeassistant.components.opentherm_gw -pyotgw==2.1.3 +pyotgw==2.2.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e3ab79efa3..e6b9e3e2ef9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1591,7 +1591,7 @@ pyopnsense==0.4.0 pyosoenergyapi==1.1.3 # homeassistant.components.opentherm_gw -pyotgw==2.1.3 +pyotgw==2.2.0 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp From 86f96db9b01ee671b18d1978e70c7dd7caa3fd38 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 May 2024 00:00:30 +0200 Subject: [PATCH 261/272] Improve asuswrt decorator typing (#116563) --- homeassistant/components/asuswrt/bridge.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index c177fb1bb20..579f894ff61 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -66,10 +66,12 @@ _ReturnFuncType = Callable[[_AsusWrtBridgeT], Coroutine[Any, Any, dict[str, Any] def handle_errors_and_zip( exceptions: type[Exception] | tuple[type[Exception], ...], keys: list[str] | None -) -> Callable[[_FuncType], _ReturnFuncType]: +) -> Callable[[_FuncType[_AsusWrtBridgeT]], _ReturnFuncType[_AsusWrtBridgeT]]: """Run library methods and zip results or manage exceptions.""" - def _handle_errors_and_zip(func: _FuncType) -> _ReturnFuncType: + def _handle_errors_and_zip( + func: _FuncType[_AsusWrtBridgeT], + ) -> _ReturnFuncType[_AsusWrtBridgeT]: """Run library methods and zip results or manage exceptions.""" @functools.wraps(func) From 2cb3a31db1d05a57ab3484cc4457ab6a8589720f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 May 2024 00:00:47 +0200 Subject: [PATCH 262/272] Improve fitbit generic coordinator typing (#116562) --- homeassistant/components/fitbit/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fitbit/coordinator.py b/homeassistant/components/fitbit/coordinator.py index 5c156955f90..2126129d261 100644 --- a/homeassistant/components/fitbit/coordinator.py +++ b/homeassistant/components/fitbit/coordinator.py @@ -20,7 +20,7 @@ UPDATE_INTERVAL: Final = datetime.timedelta(minutes=30) TIMEOUT = 10 -class FitbitDeviceCoordinator(DataUpdateCoordinator): +class FitbitDeviceCoordinator(DataUpdateCoordinator[dict[str, FitbitDevice]]): """Coordinator for fetching fitbit devices from the API.""" def __init__(self, hass: HomeAssistant, api: FitbitApi) -> None: From afbe0ce096ec04394954f0dd19f0a2981695fb9a Mon Sep 17 00:00:00 2001 From: Tomasz Date: Thu, 2 May 2024 00:21:40 +0200 Subject: [PATCH 263/272] Bump sanix to 1.0.6 (#116570) dependency version bump --- homeassistant/components/sanix/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sanix/manifest.json b/homeassistant/components/sanix/manifest.json index 4e1c6d56add..facf8f7a4dd 100644 --- a/homeassistant/components/sanix/manifest.json +++ b/homeassistant/components/sanix/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sanix", "iot_class": "cloud_polling", - "requirements": ["sanix==1.0.5"] + "requirements": ["sanix==1.0.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9dade9b7c4b..384b0fafbd4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2502,7 +2502,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.sanix -sanix==1.0.5 +sanix==1.0.6 # homeassistant.components.satel_integra satel-integra==0.3.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6b9e3e2ef9..bf4927b471f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1942,7 +1942,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.sanix -sanix==1.0.5 +sanix==1.0.6 # homeassistant.components.screenlogic screenlogicpy==0.10.0 From 713ce0dd17dbbd4f6adae419b62589c38b90565b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 2 May 2024 02:19:40 +0200 Subject: [PATCH 264/272] Fix Airthings BLE model names (#116579) --- homeassistant/components/airthings_ble/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 8031b802eae..3b012ed7316 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -225,7 +225,7 @@ class AirthingsSensor( manufacturer=airthings_device.manufacturer, hw_version=airthings_device.hw_version, sw_version=airthings_device.sw_version, - model=airthings_device.model.name, + model=airthings_device.model.product_name, ) @property From 657c9ec25b91d5cbe6f6fcab106b54617941fe85 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 May 2024 19:23:43 -0500 Subject: [PATCH 265/272] Add a lock to homekit_controller platform loads (#116539) --- .../homekit_controller/connection.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 78beb7bfffa..78190634aff 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -153,6 +153,7 @@ class HKDevice: self._subscriptions: dict[tuple[int, int], set[CALLBACK_TYPE]] = {} self._pending_subscribes: set[tuple[int, int]] = set() self._subscribe_timer: CALLBACK_TYPE | None = None + self._load_platforms_lock = asyncio.Lock() @property def entity_map(self) -> Accessories: @@ -327,7 +328,8 @@ class HKDevice: ) # BLE devices always get an RSSI sensor as well if "sensor" not in self.platforms: - await self._async_load_platforms({"sensor"}) + async with self._load_platforms_lock: + await self._async_load_platforms({"sensor"}) @callback def _async_start_polling(self) -> None: @@ -804,6 +806,7 @@ class HKDevice: async def _async_load_platforms(self, platforms: set[str]) -> None: """Load a group of platforms.""" + assert self._load_platforms_lock.locked(), "Must be called with lock held" if not (to_load := platforms - self.platforms): return self.platforms.update(to_load) @@ -813,22 +816,23 @@ class HKDevice: async def async_load_platforms(self) -> None: """Load any platforms needed by this HomeKit device.""" - to_load: set[str] = set() - for accessory in self.entity_map.accessories: - for service in accessory.services: - if service.type in HOMEKIT_ACCESSORY_DISPATCH: - platform = HOMEKIT_ACCESSORY_DISPATCH[service.type] - if platform not in self.platforms: - to_load.add(platform) - - for char in service.characteristics: - if char.type in CHARACTERISTIC_PLATFORMS: - platform = CHARACTERISTIC_PLATFORMS[char.type] + async with self._load_platforms_lock: + to_load: set[str] = set() + for accessory in self.entity_map.accessories: + for service in accessory.services: + if service.type in HOMEKIT_ACCESSORY_DISPATCH: + platform = HOMEKIT_ACCESSORY_DISPATCH[service.type] if platform not in self.platforms: to_load.add(platform) - if to_load: - await self._async_load_platforms(to_load) + for char in service.characteristics: + if char.type in CHARACTERISTIC_PLATFORMS: + platform = CHARACTERISTIC_PLATFORMS[char.type] + if platform not in self.platforms: + to_load.add(platform) + + if to_load: + await self._async_load_platforms(to_load) @callback def async_update_available_state(self, *_: Any) -> None: From 62a87b84309ab3585431e048ff8bdbd8b3046679 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Wed, 1 May 2024 20:51:04 -0400 Subject: [PATCH 266/272] Bump elkm1_lib to 2.2.7 (#116564) Co-authored-by: J. Nick Koston --- 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 3ec5be46d41..5edab8463f7 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.6"] + "requirements": ["elkm1-lib==2.2.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 384b0fafbd4..5073f43e39d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -777,7 +777,7 @@ elgato==5.1.2 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.6 +elkm1-lib==2.2.7 # homeassistant.components.elmax elmax-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf4927b471f..e9589fe399e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ electrickiwi-api==0.8.5 elgato==5.1.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.6 +elkm1-lib==2.2.7 # homeassistant.components.elmax elmax-api==0.0.4 From 67e199fb2f0c7940903176501a9ca5a07f88507a Mon Sep 17 00:00:00 2001 From: David Knowles Date: Thu, 2 May 2024 01:56:27 -0400 Subject: [PATCH 267/272] Bump pydrawise to 2024.4.1 (#116449) * Bump pydrawise to 2024.4.1 * Fix typing errors * Use assert instead of cast * Remove unused import --- homeassistant/components/hydrawise/binary_sensor.py | 6 ++---- homeassistant/components/hydrawise/manifest.json | 2 +- homeassistant/components/hydrawise/switch.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index a93976b12e0..b8c5dbddc7c 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from pydrawise.schema import Zone - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -65,5 +63,5 @@ class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): if self.entity_description.key == "status": self._attr_is_on = self.coordinator.last_update_success elif self.entity_description.key == "is_watering": - zone: Zone = self.zone - self._attr_is_on = zone.scheduled_runs.current_run is not None + assert self.zone is not None + self._attr_is_on = self.zone.scheduled_runs.current_run is not None diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 5181de7d2a4..8a0d52d550c 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["pydrawise"], - "requirements": ["pydrawise==2024.3.0"] + "requirements": ["pydrawise==2024.4.1"] } diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index 2dc459e7dd4..bceaa85eb73 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -63,7 +63,8 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """Turn the device on.""" if self.entity_description.key == "manual_watering": await self.coordinator.api.start_zone( - self.zone, custom_run_duration=DEFAULT_WATERING_TIME.total_seconds() + self.zone, + custom_run_duration=int(DEFAULT_WATERING_TIME.total_seconds()), ) elif self.entity_description.key == "auto_watering": await self.coordinator.api.resume_zone(self.zone) diff --git a/requirements_all.txt b/requirements_all.txt index 5073f43e39d..bd6cfed0971 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1779,7 +1779,7 @@ pydiscovergy==3.0.0 pydoods==1.0.2 # homeassistant.components.hydrawise -pydrawise==2024.3.0 +pydrawise==2024.4.1 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e9589fe399e..2dc798b8d26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1390,7 +1390,7 @@ pydexcom==0.2.3 pydiscovergy==3.0.0 # homeassistant.components.hydrawise -pydrawise==2024.3.0 +pydrawise==2024.4.1 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 From 86637f71713f0a55169c1eac27a0e26beb200b62 Mon Sep 17 00:00:00 2001 From: Thomas55555 <59625598+Thomas55555@users.noreply.github.com> Date: Thu, 2 May 2024 10:05:45 +0200 Subject: [PATCH 268/272] Address late review for Husqvarna Automower (#116536) * Address late review for Husqvarna Automower * fix wrong base entity --- .../components/husqvarna_automower/number.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/husqvarna_automower/number.py b/homeassistant/components/husqvarna_automower/number.py index a3458cd319b..bcf74ac4d33 100644 --- a/homeassistant/components/husqvarna_automower/number.py +++ b/homeassistant/components/husqvarna_automower/number.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .coordinator import AutomowerDataUpdateCoordinator -from .entity import AutomowerBaseEntity +from .entity import AutomowerControlEntity _LOGGER = logging.getLogger(__name__) @@ -125,7 +125,7 @@ async def async_setup_entry( for description in WORK_AREA_NUMBER_TYPES for work_area_id in _work_areas ) - await async_remove_entities(coordinator, hass, entry, mower_id) + async_remove_entities(hass, coordinator, entry, mower_id) entities.extend( AutomowerNumberEntity(mower_id, coordinator, description) for mower_id in coordinator.data @@ -135,7 +135,7 @@ async def async_setup_entry( async_add_entities(entities) -class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity): +class AutomowerNumberEntity(AutomowerControlEntity, NumberEntity): """Defining the AutomowerNumberEntity with AutomowerNumberEntityDescription.""" entity_description: AutomowerNumberEntityDescription @@ -168,7 +168,7 @@ class AutomowerNumberEntity(AutomowerBaseEntity, NumberEntity): ) from exception -class AutomowerWorkAreaNumberEntity(AutomowerBaseEntity, NumberEntity): +class AutomowerWorkAreaNumberEntity(AutomowerControlEntity, NumberEntity): """Defining the AutomowerWorkAreaNumberEntity with AutomowerWorkAreaNumberEntityDescription.""" entity_description: AutomowerWorkAreaNumberEntityDescription @@ -216,24 +216,25 @@ class AutomowerWorkAreaNumberEntity(AutomowerBaseEntity, NumberEntity): ) from exception -async def async_remove_entities( - coordinator: AutomowerDataUpdateCoordinator, +@callback +def async_remove_entities( hass: HomeAssistant, + coordinator: AutomowerDataUpdateCoordinator, config_entry: ConfigEntry, mower_id: str, ) -> None: """Remove deleted work areas from Home Assistant.""" entity_reg = er.async_get(hass) - work_area_list = [] + active_work_areas = set() _work_areas = coordinator.data[mower_id].work_areas if _work_areas is not None: for work_area_id in _work_areas: uid = f"{mower_id}_{work_area_id}_cutting_height_work_area" - work_area_list.append(uid) + active_work_areas.add(uid) for entity_entry in er.async_entries_for_config_entry( entity_reg, config_entry.entry_id ): if entity_entry.unique_id.split("_")[0] == mower_id: if entity_entry.unique_id.endswith("cutting_height_work_area"): - if entity_entry.unique_id not in work_area_list: + if entity_entry.unique_id not in active_work_areas: entity_reg.async_remove(entity_entry.entity_id) From 9c3a4c53656859f5e0fb6f1ecaa5bb9590855a63 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 10:18:44 +0200 Subject: [PATCH 269/272] Bump sigstore/cosign-installer from 3.4.0 to 3.5.0 (#115399) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 3.4.0 to 3.5.0. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/v3.4.0...v3.5.0) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index a72c4e75cfe..5cbfb4b0602 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -323,7 +323,7 @@ jobs: uses: actions/checkout@v4.1.4 - name: Install Cosign - uses: sigstore/cosign-installer@v3.4.0 + uses: sigstore/cosign-installer@v3.5.0 with: cosign-release: "v2.2.3" From 71c5f33e69800cdbf37d75055cf400f9cea107b2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 May 2024 10:24:36 +0200 Subject: [PATCH 270/272] Bump codecov/codecov-action from 4.3.0 to 4.3.1 (#116592) Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.3.0 to 4.3.1. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/codecov/codecov-action/compare/v4.3.0...v4.3.1) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 40a3b064887..10353f39bdb 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1106,7 +1106,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'true' - uses: codecov/codecov-action@v4.3.0 + uses: codecov/codecov-action@v4.3.1 with: fail_ci_if_error: true flags: full-suite @@ -1240,7 +1240,7 @@ jobs: pattern: coverage-* - name: Upload coverage to Codecov if: needs.info.outputs.test_full_suite == 'false' - uses: codecov/codecov-action@v4.3.0 + uses: codecov/codecov-action@v4.3.1 with: fail_ci_if_error: true token: ${{ secrets.CODECOV_TOKEN }} From 1170ce1296663629daac7848e607465fb219fa3f Mon Sep 17 00:00:00 2001 From: blob810 <38312074+blob810@users.noreply.github.com> Date: Thu, 2 May 2024 11:02:35 +0200 Subject: [PATCH 271/272] Add shutter tilt support for Shelly Wave Shutter QNSH-001P10 (#116211) * Add shutter tilt support for Shelly Wave Shutter QNSH-001P10 * Add shelly_europe_ltd_qnsh_001p10_state.json fixture * Update test_discovery.py * Load shelly wave shutter 001p10 node fixture * Update test_discovery.py Check if entity of first node exists. * Update test_discovery.py Add additional comments * Clean whitespace --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 55 + tests/components/zwave_js/conftest.py | 16 + .../shelly_europe_ltd_qnsh_001p10_state.json | 2049 +++++++++++++++++ tests/components/zwave_js/test_discovery.py | 26 + 4 files changed, 2146 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/shelly_europe_ltd_qnsh_001p10_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 272f6e3ddc0..b5d0a4976e9 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -448,6 +448,61 @@ DISCOVERY_SCHEMAS = [ primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], ), + # Shelly Qubino Wave Shutter QNSH-001P10 + # Combine both switch_multilevel endpoints into shutter_tilt + # if operating mode (71) is set to venetian blind (1) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter_tilt", + manufacturer_id={0x0460}, + product_id={0x0082}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={1}, + type={ValueType.NUMBER}, + ), + data_template=CoverTiltDataTemplate( + current_tilt_value_id=ZwaveValueID( + property_=CURRENT_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + target_tilt_value_id=ZwaveValueID( + property_=TARGET_VALUE_PROPERTY, + command_class=CommandClass.SWITCH_MULTILEVEL, + endpoint=2, + ), + ), + required_values=[ + ZWaveValueDiscoverySchema( + command_class={CommandClass.CONFIGURATION}, + property={71}, + endpoint={0}, + value={1}, + ) + ], + ), + # Shelly Qubino Wave Shutter QNSH-001P10 + # Disable endpoint 2 (slat), + # as these are either combined with endpoint one as shutter_tilt + # or it has no practical function. + # CC: Switch_Multilevel + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="shutter", + manufacturer_id={0x0460}, + product_id={0x0082}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={CURRENT_VALUE_PROPERTY}, + endpoint={2}, + type={ValueType.NUMBER}, + ), + entity_registry_enabled_default=False, + ), # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index db92b89cf81..81ebd1acd6c 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -496,6 +496,12 @@ def fibaro_fgr223_shutter_state_fixture(): return json.loads(load_fixture("zwave_js/cover_fibaro_fgr223_state.json")) +@pytest.fixture(name="shelly_europe_ltd_qnsh_001p10_state", scope="package") +def shelly_europe_ltd_qnsh_001p10_state_fixture(): + """Load the Shelly QNSH 001P10 node state fixture data.""" + return json.loads(load_fixture("zwave_js/shelly_europe_ltd_qnsh_001p10_state.json")) + + @pytest.fixture(name="merten_507801_state", scope="package") def merten_507801_state_fixture(): """Load the Merten 507801 Shutter node state fixture data.""" @@ -1095,6 +1101,16 @@ def fibaro_fgr223_shutter_cover_fixture(client, fibaro_fgr223_shutter_state): return node +@pytest.fixture(name="shelly_qnsh_001P10_shutter") +def shelly_qnsh_001P10_cover_shutter_fixture( + client, shelly_europe_ltd_qnsh_001p10_state +): + """Mock a Shelly QNSH 001P10 Shutter node.""" + node = Node(client, copy.deepcopy(shelly_europe_ltd_qnsh_001p10_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="merten_507801") def merten_507801_cover_fixture(client, merten_507801_state): """Mock a Merten 507801 Shutter node.""" diff --git a/tests/components/zwave_js/fixtures/shelly_europe_ltd_qnsh_001p10_state.json b/tests/components/zwave_js/fixtures/shelly_europe_ltd_qnsh_001p10_state.json new file mode 100644 index 00000000000..7f38ef34f29 --- /dev/null +++ b/tests/components/zwave_js/fixtures/shelly_europe_ltd_qnsh_001p10_state.json @@ -0,0 +1,2049 @@ +{ + "nodeId": 5, + "index": 0, + "installerIcon": 6144, + "userIcon": 6144, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 1120, + "productId": 130, + "productType": 3, + "firmwareVersion": "12.17.0", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/data/db/devices/0x0460/qnsh-001P10.json", + "isEmbedded": true, + "manufacturer": "Shelly Europe Ltd.", + "manufacturerId": 1120, + "label": "QNSH-001P10", + "description": "Wave Shutter", + "devices": [ + { + "productType": 3, + "productId": 130 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "6.1 Adding the Device to a Z-Wave\u2122 network (inclusion)\nNote! All Device outputs (O, O1, O2, etc. - depending on the Device type) will turn the load 1s on/1s off /1s on/1s off if the Device is successfully added to/removed from a Z-Wave\u2122 network.\n\n6.1.1 SmartStart adding (inclusion)\nSmartStart enabled products can be added into a Z-Wave\u2122 network by scanning the Z-Wave\u2122 QR Code present on the Device with a gateway providing SmartStart inclusion. No further action is required, and the SmartStart device will be added automatically within 10 minutes of being switched on in the network vicinity.\n1. With the gateway application scan the QR code on the Device label and add the Security 2 (S2) Device Specific Key (DSK) to the provisioning list in the gateway.\n2. Connect the Device to a power supply.\n3. Check if the blue LED is blinking in Mode 1. If so, the Device is not added to a Z-Wave\u2122 network.\n4. Adding will be initiated automatically within a few seconds after connecting the Device to a power supply, and the Device will be added to a Z-Wave\u2122 network automatically.\n5. The blue LED will be blinking in Mode 2 during the adding process.\n6. The green LED will be blinking in Mode 1 if the Device is successfully added to a Z-Wave\u2122 network.\n\n6.1.2 Adding (inclusion) with a switch/push-button\n1. Connect the Device to a power supply.\n2. Check if the blue LED is blinking in Mode 1. If so, the Device is not added to a Z-Wave\u2122 network.\n3. Enable add/remove mode on the gateway.\n4. Toggle the switch/push-button connected to any of the SW terminals (SW, SW1, SW2, etc.) 3 times within 3 seconds (this procedure puts the Device in Learn mode*). The Device must receive on/off signal 3 times, which means pressing the momentary switch 3 times, or toggling the switch on and off 3 times.\n5. The blue LED will be blinking in Mode 2 during the adding process.\n6. The green LED will be blinking in Mode 1 if the Device is successfully added to a Z-Wave\u2122 network.\n*Learn mode - a state that allows the Device to receive network information from the gateway.\n\n6.1.3 Adding (inclusion) with the S button\n1. Connect the Device to a power supply.\n2. Check if the blue LED is blinking in Mode 1. If so, the Device is not added to a Z-Wave\u2122 network.\n3. Enable add/remove mode on the gateway.\n4. To enter the Setting mode, quickly press and hold the S button on the Device until the LED turns solid blue.\n5. Quickly release and then press and hold (> 2s) the S button on the Device until the blue LED starts blinking in Mode 3. Releasing the S button will start the Learn mode.\n6. The blue LED will be blinking in Mode 2 during the adding process.\n7. The green LED will be blinking in Mode 1 if the Device is successfully added to a Z-Wave\u2122 network.\nNote! In Setting mode, the Device has a timeout of 10s before entering again into Normal mode", + "exclusion": "Removing the Device from a Z-Wave\u2122 network (exclusion)\nNote! The Device will be removed from your Z-wave\u2122 network, but any custom configuration parameters will not be erased.\nNote! All Device outputs (O, O1, O2, etc. - depending on the Device type) will turn the load 1s on/1s off /1s on/1s off if the Device is successfully added to/removed from a Z-Wave\u2122 network.\n\n6.2.1 Removing (exclusion) with a switch/push-button\n1. Connect the Device to a power supply.\n2. Check if the green LED is blinking in Mode 1. If so, the Device is added to a Z-Wave\u2122 network.\n3. Enable add/remove mode on the gateway.\n4. Toggle the switch/push-button connected to any of the SW terminals (SW, SW1, SW2,\u2026) 3 times within 3 seconds (this procedure puts the Device in Learn mode). The Device must receive on/off signal 3 times, which means pressing the momentary switch 3 times, or toggling the switch on and off 3 times.\n5. The blue LED will be blinking in Mode 2 during the removing process.\n6. The blue LED will be blinking in Mode 1 if the Device is successfully removed from a Z-Wave\u2122 network.\n\n6.2.2 Removing (exclusion) with the S button\n1. Connect the Device to a power supply.\n2. Check if the green LED is blinking in Mode 1. If so, the Device is added to a Z-Wave\u2122 network.\n3. Enable add/remove mode on the gateway.\n4. To enter the Setting mode, quickly press and hold the S button on the Device until the LED turns solid blue.\n5. Quickly release and then press and hold (> 2s) the S button on the Device until the blue LED starts blinking in Mode 3. Releasing the S button will start the Learn mode.\n6. The blue LED will be blinking in Mode 2 during the removing process.\n7. The blue LED will be blinking in Mode 1 if the Device is successfully removed from a Z-Wave\u2122 network.\nNote! In Setting mode, the Device has a timeout of 10s before entering again into Normal mode", + "reset": "6.3 Factory reset\n6.3.1 Factory reset general\nAfter Factory reset, all custom parameters and stored values (kWh, associations, routings, etc.) will return to their default state. HOME ID and NODE ID assigned to the Device will be deleted. Use this reset procedure only when the gateway is missing or otherwise inoperable.\n\n6.3.2 Factory reset with a switch/push-button\nNote! Factory reset with a switch/push-button is only possible within the first minute after the Device is connected to a power supply.\n1. Connect the Device to a power supply.\n2. Toggle the switch/push-button connected to any of the SW terminals (SW, SW1, SW2,\u2026) 5 times within 3 seconds. The Device must receive on/off signal 5 times, which means pressing the push-button 5 times, or toggling the switch on and off 5 times.\n3. During factory reset, the LED will turn solid green for about 1s, then the blue and red LED will start blinking in Mode 3 for approx. 2s.\n4. The blue LED will be blinking in Mode 1 if the Factory reset is successful.\n\n6.3.3 Factory reset with the S button\nNote! Factory reset with the S button is possible anytime.\n1. To enter the Setting mode, quickly press and hold the S button on the Device until the LED turns solid blue.\n2. Press the S button multiple times until the LED turns solid red.\n3. Press and hold (> 2s) S button on the Device until the red LED starts blinking in Mode 3. Releasing the S button will start the factory reset.\n4. During factory reset, the LED will turn solid green for about 1s, then the blue and red LED will start blinking in Mode 3 for approx. 2s.\n5. The blue LED will be blinking in Mode 1 if the Factory reset is successful.\n\n6.3.4 Remote factory reset with parameter with the gateway\nFactory reset can be done remotely with the settings in Parameter No. 120" + } + }, + "label": "QNSH-001P10", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 2, + "aggregatedEndpointCount": 0, + "interviewAttempts": 0, + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0460:0x0003:0x0082:12.17.0", + "statistics": { + "commandsTX": 1, + "commandsRX": 0, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 25.1, + "lastSeen": "2024-04-26T13:30:44.411Z", + "rssi": -95, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -95, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false, + "lastSeen": "2024-04-26T13:30:44.411Z", + "values": [ + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "SW1 Switch Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "SW1 Switch Type", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Momentary switch", + "1": "Toggle switch (Follow switch)", + "2": "Toggle switch (Change on toggle)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Swap Inputs", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Swap Inputs", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Normal (SW1 - O1, SW2 - O2)", + "1": "Swapped (SW1 - O2, SW2 - O1)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Swap Outputs", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Swap Outputs", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Normal", + "1": "Swapped (O1 - close, O2 - open)" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Power Change Report Threshold", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Power Change Report Threshold", + "default": 50, + "min": 0, + "max": 100, + "unit": "%", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 71, + "propertyName": "Operating Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Operating Mode", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Shutter", + "1": "Venetian", + "2": "Manual time" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 72, + "propertyName": "Venetian Mode: Turning Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Time required for the slats to make a full turn (180\u00b0)", + "label": "Venetian Mode: Turning Time", + "default": 150, + "min": 0, + "max": 32767, + "states": { + "0": "Disabled" + }, + "unit": "0.01 seconds", + "valueSize": 2, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 150 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 73, + "propertyName": "Venetian Mode: Restore Slats Position After Moving", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Venetian Mode: Restore Slats Position After Moving", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 76, + "propertyName": "Motor Operation Detection", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Power consumption threshold at the end positions", + "label": "Motor Operation Detection", + "default": 1, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "1": "Auto" + }, + "unit": "W", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 78, + "propertyName": "Shutter Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Shutter Calibration", + "default": 3, + "min": 1, + "max": 4, + "states": { + "1": "Start calibration", + "2": "Calibrated (Read only)", + "3": "Not calibrated (Read only)", + "4": "Calibration error (Read only)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 80, + "propertyName": "Delay Motor Stop", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "How long to wait before stopping the motor after reaching the end position", + "label": "Delay Motor Stop", + "default": 10, + "min": 0, + "max": 255, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 85, + "propertyName": "Power Consumption Measurement Delay", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Allowable range: 0, 3-50", + "label": "Power Consumption Measurement Delay", + "default": 30, + "min": 0, + "max": 50, + "states": { + "0": "Auto" + }, + "unit": "0.1 seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 30 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 91, + "propertyName": "Motor Moving Time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Allowable range: 1-32000, 65000", + "label": "Motor Moving Time", + "default": 120, + "min": 0, + "max": 65000, + "states": { + "65000": "Unlimited" + }, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 12000 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 51, + "propertyName": "Alarm conf. - Water", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": " 0 no action 1 open blinds 2 close blinds", + "label": "Alarm conf. - Water", + "default": 0, + "min": 0, + "max": 2, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 52, + "propertyName": "Alarm conf. - Smoke", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": " 0 no action 1 open blinds 2 close blinds", + "label": "Alarm conf. - Smoke", + "default": 0, + "min": 0, + "max": 2, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 53, + "propertyName": "Alarm conf. - CO", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": " 0 no action 1 open blinds 2 close blinds", + "label": "Alarm conf. - CO", + "default": 0, + "min": 0, + "max": 2, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 54, + "propertyName": "Alarm conf. - Heat", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": " 0 no action 1 open blinds 2 close blinds", + "label": "Alarm conf. - Heat", + "default": 0, + "min": 0, + "max": 2, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 74, + "propertyName": "Up time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0 - 65535, 0.1 s units", + "label": "Up time", + "default": 0, + "min": 0, + "max": 65535, + "valueSize": 2, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 5186 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 75, + "propertyName": "Down time", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0 - 65535, 0.1 s units", + "label": "Down time", + "default": 600, + "min": 0, + "max": 65535, + "valueSize": 2, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 5152 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 77, + "propertyName": "Slats turning time offset", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "0 - 255, 0.01 s units", + "label": "Slats turning time offset", + "default": 10, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 90, + "propertyName": "Next move delay", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "5 - 50 , 0.1 s units", + "label": "Next move delay", + "default": 5, + "min": 5, + "max": 50, + "valueSize": 1, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 120, + "propertyName": "Factory reset", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Factory reset", + "label": "Factory reset", + "default": 0, + "min": 0, + "max": 1431655765, + "valueSize": 4, + "format": 1, + "noBulkSupport": false, + "isAdvanced": false, + "requiresReInclusion": false, + "allowManualEntry": true, + "isFromConfig": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 1120 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 130 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.19" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["12.17", "2.1"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.19.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "10.19.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 144 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.19.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 144 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "12.17.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 144 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "states": { + "true": "Identify" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": true, + "label": "Timeout", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 6, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 6, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyKey": 1, + "propertyName": "reset", + "propertyKeyName": "Electric", + "ccVersion": 6, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset (Electric)", + "ccSpecific": { + "meterType": 1 + }, + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 6, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Overheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 65537, + "propertyName": "value", + "propertyKeyName": "Electric_kWh_Consumed", + "ccVersion": 6, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [kWh]", + "ccSpecific": { + "meterType": 1, + "scale": 0, + "rateType": 1 + }, + "unit": "kWh", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 50, + "commandClassName": "Meter", + "property": "value", + "propertyKey": 66049, + "propertyName": "value", + "propertyKeyName": "Electric_W_Consumed", + "ccVersion": 6, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Electric Consumption [W]", + "ccSpecific": { + "meterType": 1, + "scale": 2, + "rateType": 1 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyKey": 1, + "propertyName": "reset", + "propertyKeyName": "Electric", + "ccVersion": 6, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset (Electric)", + "ccSpecific": { + "meterType": 1 + }, + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 50, + "commandClassName": "Meter", + "property": "reset", + "propertyName": "reset", + "ccVersion": 6, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Reset accumulated values", + "states": { + "true": "Reset" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Heat Alarm", + "propertyKey": "Heat sensor status", + "propertyName": "Heat Alarm", + "propertyKeyName": "Heat sensor status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Heat sensor status", + "ccSpecific": { + "notificationType": 4 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "2": "Overheat detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Power Management", + "propertyKey": "Over-current status", + "propertyName": "Power Management", + "propertyKeyName": "Over-current status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Over-current status", + "ccSpecific": { + "notificationType": 8 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "6": "Over-current detected" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "1": "System hardware failure" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmType", + "propertyName": "alarmType", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Type", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 113, + "commandClassName": "Notification", + "property": "alarmLevel", + "propertyName": "alarmLevel", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Alarm Level", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + } + ], + "endpoints": [ + { + "nodeId": 5, + "index": 0, + "installerIcon": 6144, + "userIcon": 6144, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 6, + "isSecure": true + } + ] + }, + { + "nodeId": 5, + "index": 1, + "installerIcon": 6144, + "userIcon": 6144, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 6, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + }, + { + "nodeId": 5, + "index": 2, + "installerIcon": 6144, + "userIcon": 6144, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 6, + "label": "Motor Control Class B" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 50, + "name": "Meter", + "version": 6, + "isSecure": true + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": false + } + ] + } + ] +} diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index fe231707629..6612b04f4e7 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -134,6 +134,32 @@ async def test_merten_507801( assert state +async def test_shelly_001p10_disabled_entities( + hass: HomeAssistant, client, shelly_qnsh_001P10_shutter, integration +) -> None: + """Test that Shelly 001P10 entity created by endpoint 2 is disabled.""" + registry = er.async_get(hass) + entity_ids = [ + "cover.wave_shutter_2", + ] + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state is None + entry = registry.async_get(entity_id) + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + # Test enabling entity + updated_entry = registry.async_update_entity(entry.entity_id, disabled_by=None) + assert updated_entry != entry + assert updated_entry.disabled is False + + # Test if the main entity from endpoint 1 was created. + state = hass.states.get("cover.wave_shutter") + assert state + + async def test_merten_507801_disabled_enitites( hass: HomeAssistant, client, merten_507801, integration ) -> None: From 8eb197072151fe7e65d7c8d3c31209b7022c7b7c Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Thu, 2 May 2024 11:44:32 +0200 Subject: [PATCH 272/272] Fix inheritance order for KNX notify (#116600) --- homeassistant/components/knx/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py index e208e4fd646..f206ee62ece 100644 --- a/homeassistant/components/knx/notify.py +++ b/homeassistant/components/knx/notify.py @@ -97,7 +97,7 @@ def _create_notification_instance(xknx: XKNX, config: ConfigType) -> XknxNotific ) -class KNXNotify(NotifyEntity, KnxEntity): +class KNXNotify(KnxEntity, NotifyEntity): """Representation of a KNX notification entity.""" _device: XknxNotification